1 line
1.3 MiB
Plaintext
1 line
1.3 MiB
Plaintext
{"repo": "carlosvidal/proxy-ai-text-enhancer-ci4", "files": {"app/Controllers/LlmProxy.php": {"db999a4a31502c2a31eb3248fb1399dce27d7056": {"t": 1785746174.5198545, "n": 53155, "h": "d1776508e2ab3416", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (strtolower(service('request')->getMethod()) !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n // Models that can accept images. NOTE: Anthropic models are vision-capable\n // at the API level, but AnthropicProvider currently forwards text only, so\n // Claude is intentionally excluded here — that makes an image request fail\n // loudly instead of being silently stripped. Add Claude back once the\n // provider forwards image content blocks.\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4.1',\n 'gpt-4.1-mini',\n 'gpt-4-turbo',\n // Google Gemini (multimodal)\n 'gemini-2.0-flash',\n 'gemini-1.5-pro',\n 'gemini-1.5-flash',\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4o, gpt-4.1 o gemini-2.0-flash.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing. Guard against $api_user being\n // unset on this (non-streaming) branch — fall back to the pre-call\n // quota snapshot computed earlier ($quotaInfo) instead of erroring.\n $finalQuotaCheck = (isset($api_user) && $api_user)\n ? $this->_check_user_quota($api_user, $button['tenant_id'])\n : ($quotaInfo ?? ['monthly_remaining' => 0, 'daily_remaining' => 0, 'monthly_used' => 0, 'daily_used' => 0]);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n // Approximate per-token rates for usage cost estimation (input pricing,\n // USD/token). These drift — verify against each provider's pricing page.\n $rates = [\n 'openai' => [\n 'gpt-4o' => 0.0000025, // $2.50 / 1M\n 'gpt-4o-mini' => 0.00000015, // $0.15 / 1M\n 'gpt-4.1' => 0.000002, // $2.00 / 1M\n 'gpt-4.1-mini' => 0.0000004, // $0.40 / 1M\n 'gpt-4-turbo' => 0.00001, // $10.00 / 1M\n 'gpt-3.5-turbo' => 0.0000005, // $0.50 / 1M\n 'default' => 0.0000025\n ],\n 'anthropic' => [\n 'claude-opus-4-8' => 0.000005, // $5.00 / 1M\n 'claude-sonnet-4-6' => 0.000003, // $3.00 / 1M\n 'claude-haiku-4-5' => 0.000001, // $1.00 / 1M\n 'claude-fable-5' => 0.00001, // $10.00 / 1M\n 'default' => 0.000003\n ],\n 'mistral' => [\n 'mistral-large-latest' => 0.000002,\n 'mistral-medium-latest' => 0.0000004,\n 'mistral-small-latest' => 0.0000002,\n 'default' => 0.000001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000027, // ~$0.27 / 1M\n 'deepseek-reasoner' => 0.00000055, // ~$0.55 / 1M\n 'default' => 0.00000027\n ],\n 'google' => [\n 'gemini-1.5-pro' => 0.00000125,\n 'gemini-1.5-flash' => 0.000000075,\n 'gemini-2.0-flash' => 0.0000001,\n 'default' => 0.00000125\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n // Remover barra final y espacios\n $domain = rtrim(trim($domain), '/');\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n\n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain, tenant_id FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n $tenantIdsForDomainLookup = [];\n\n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // If domain is __tenant__, resolve from domains table\n if ($row['domain'] === '__tenant__') {\n $tenantIdsForDomainLookup[] = $row['tenant_id'];\n continue;\n }\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = rtrim(trim($domain), '/');\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n\n // Resolve __tenant__ domains from the domains table\n if (!empty($tenantIdsForDomainLookup)) {\n $tenantIdsForDomainLookup = array_unique($tenantIdsForDomainLookup);\n $placeholders = implode(',', array_fill(0, count($tenantIdsForDomainLookup), '?'));\n $domainsQuery = $db->query(\n \"SELECT DISTINCT domain FROM domains WHERE tenant_id IN ({$placeholders})\",\n $tenantIdsForDomainLookup\n );\n\n if ($domainsQuery) {\n foreach ($domainsQuery->getResultArray() as $row) {\n $domain = rtrim(trim($row['domain']), '/');\n if (!empty($domain)) {\n $cleanDomain = preg_replace('#^https?://#', '', $domain);\n $cleanDomain = rtrim($cleanDomain, '/');\n $allowedDomains[] = 'https://' . $cleanDomain;\n $allowedDomains[] = 'http://' . $cleanDomain;\n $allowedDomains[] = $cleanDomain;\n }\n }\n }\n }\n\n // Check if origin is allowed (exact match or host-only match)\n $originAllowed = false;\n $originHost = parse_url($origin, PHP_URL_HOST);\n\n foreach ($allowedDomains as $allowed) {\n if ($origin === $allowed) {\n $originAllowed = true;\n break;\n }\n // Host-only fallback\n $allowedHost = parse_url($allowed, PHP_URL_HOST) ?: $allowed;\n if ($originHost === $allowedHost) {\n $originAllowed = true;\n break;\n }\n }\n\n if ($originAllowed || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n\n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "8eca55325e4a3078ca1946578b69abd5410315e4": {"t": 1785760315.1570559, "n": 53155, "h": "d1776508e2ab3416", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (strtolower(service('request')->getMethod()) !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n // Models that can accept images. NOTE: Anthropic models are vision-capable\n // at the API level, but AnthropicProvider currently forwards text only, so\n // Claude is intentionally excluded here — that makes an image request fail\n // loudly instead of being silently stripped. Add Claude back once the\n // provider forwards image content blocks.\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4.1',\n 'gpt-4.1-mini',\n 'gpt-4-turbo',\n // Google Gemini (multimodal)\n 'gemini-2.0-flash',\n 'gemini-1.5-pro',\n 'gemini-1.5-flash',\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4o, gpt-4.1 o gemini-2.0-flash.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing. Guard against $api_user being\n // unset on this (non-streaming) branch — fall back to the pre-call\n // quota snapshot computed earlier ($quotaInfo) instead of erroring.\n $finalQuotaCheck = (isset($api_user) && $api_user)\n ? $this->_check_user_quota($api_user, $button['tenant_id'])\n : ($quotaInfo ?? ['monthly_remaining' => 0, 'daily_remaining' => 0, 'monthly_used' => 0, 'daily_used' => 0]);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n // Approximate per-token rates for usage cost estimation (input pricing,\n // USD/token). These drift — verify against each provider's pricing page.\n $rates = [\n 'openai' => [\n 'gpt-4o' => 0.0000025, // $2.50 / 1M\n 'gpt-4o-mini' => 0.00000015, // $0.15 / 1M\n 'gpt-4.1' => 0.000002, // $2.00 / 1M\n 'gpt-4.1-mini' => 0.0000004, // $0.40 / 1M\n 'gpt-4-turbo' => 0.00001, // $10.00 / 1M\n 'gpt-3.5-turbo' => 0.0000005, // $0.50 / 1M\n 'default' => 0.0000025\n ],\n 'anthropic' => [\n 'claude-opus-4-8' => 0.000005, // $5.00 / 1M\n 'claude-sonnet-4-6' => 0.000003, // $3.00 / 1M\n 'claude-haiku-4-5' => 0.000001, // $1.00 / 1M\n 'claude-fable-5' => 0.00001, // $10.00 / 1M\n 'default' => 0.000003\n ],\n 'mistral' => [\n 'mistral-large-latest' => 0.000002,\n 'mistral-medium-latest' => 0.0000004,\n 'mistral-small-latest' => 0.0000002,\n 'default' => 0.000001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000027, // ~$0.27 / 1M\n 'deepseek-reasoner' => 0.00000055, // ~$0.55 / 1M\n 'default' => 0.00000027\n ],\n 'google' => [\n 'gemini-1.5-pro' => 0.00000125,\n 'gemini-1.5-flash' => 0.000000075,\n 'gemini-2.0-flash' => 0.0000001,\n 'default' => 0.00000125\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n // Remover barra final y espacios\n $domain = rtrim(trim($domain), '/');\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n\n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain, tenant_id FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n $tenantIdsForDomainLookup = [];\n\n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // If domain is __tenant__, resolve from domains table\n if ($row['domain'] === '__tenant__') {\n $tenantIdsForDomainLookup[] = $row['tenant_id'];\n continue;\n }\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = rtrim(trim($domain), '/');\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n\n // Resolve __tenant__ domains from the domains table\n if (!empty($tenantIdsForDomainLookup)) {\n $tenantIdsForDomainLookup = array_unique($tenantIdsForDomainLookup);\n $placeholders = implode(',', array_fill(0, count($tenantIdsForDomainLookup), '?'));\n $domainsQuery = $db->query(\n \"SELECT DISTINCT domain FROM domains WHERE tenant_id IN ({$placeholders})\",\n $tenantIdsForDomainLookup\n );\n\n if ($domainsQuery) {\n foreach ($domainsQuery->getResultArray() as $row) {\n $domain = rtrim(trim($row['domain']), '/');\n if (!empty($domain)) {\n $cleanDomain = preg_replace('#^https?://#', '', $domain);\n $cleanDomain = rtrim($cleanDomain, '/');\n $allowedDomains[] = 'https://' . $cleanDomain;\n $allowedDomains[] = 'http://' . $cleanDomain;\n $allowedDomains[] = $cleanDomain;\n }\n }\n }\n }\n\n // Check if origin is allowed (exact match or host-only match)\n $originAllowed = false;\n $originHost = parse_url($origin, PHP_URL_HOST);\n\n foreach ($allowedDomains as $allowed) {\n if ($origin === $allowed) {\n $originAllowed = true;\n break;\n }\n // Host-only fallback\n $allowedHost = parse_url($allowed, PHP_URL_HOST) ?: $allowed;\n if ($originHost === $allowedHost) {\n $originAllowed = true;\n break;\n }\n }\n\n if ($originAllowed || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n\n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "76f4b5d82256c89c3980e02023584755775e93fd": {"t": 1785760316.7230608, "n": 52772, "h": "3dfe8d9d232848bf", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (strtolower(service('request')->getMethod()) !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n // Models that can accept images. NOTE: Anthropic models are vision-capable\n // at the API level, but AnthropicProvider currently forwards text only, so\n // Claude is intentionally excluded here — that makes an image request fail\n // loudly instead of being silently stripped. Add Claude back once the\n // provider forwards image content blocks.\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4.1',\n 'gpt-4.1-mini',\n 'gpt-4-turbo',\n // Google Gemini (multimodal)\n 'gemini-2.0-flash',\n 'gemini-1.5-pro',\n 'gemini-1.5-flash',\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4o, gpt-4.1 o gemini-2.0-flash.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n // Approximate per-token rates for usage cost estimation (input pricing,\n // USD/token). These drift — verify against each provider's pricing page.\n $rates = [\n 'openai' => [\n 'gpt-4o' => 0.0000025, // $2.50 / 1M\n 'gpt-4o-mini' => 0.00000015, // $0.15 / 1M\n 'gpt-4.1' => 0.000002, // $2.00 / 1M\n 'gpt-4.1-mini' => 0.0000004, // $0.40 / 1M\n 'gpt-4-turbo' => 0.00001, // $10.00 / 1M\n 'gpt-3.5-turbo' => 0.0000005, // $0.50 / 1M\n 'default' => 0.0000025\n ],\n 'anthropic' => [\n 'claude-opus-4-8' => 0.000005, // $5.00 / 1M\n 'claude-sonnet-4-6' => 0.000003, // $3.00 / 1M\n 'claude-haiku-4-5' => 0.000001, // $1.00 / 1M\n 'claude-fable-5' => 0.00001, // $10.00 / 1M\n 'default' => 0.000003\n ],\n 'mistral' => [\n 'mistral-large-latest' => 0.000002,\n 'mistral-medium-latest' => 0.0000004,\n 'mistral-small-latest' => 0.0000002,\n 'default' => 0.000001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000027, // ~$0.27 / 1M\n 'deepseek-reasoner' => 0.00000055, // ~$0.55 / 1M\n 'default' => 0.00000027\n ],\n 'google' => [\n 'gemini-1.5-pro' => 0.00000125,\n 'gemini-1.5-flash' => 0.000000075,\n 'gemini-2.0-flash' => 0.0000001,\n 'default' => 0.00000125\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n // Remover barra final y espacios\n $domain = rtrim(trim($domain), '/');\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n\n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain, tenant_id FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n $tenantIdsForDomainLookup = [];\n\n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // If domain is __tenant__, resolve from domains table\n if ($row['domain'] === '__tenant__') {\n $tenantIdsForDomainLookup[] = $row['tenant_id'];\n continue;\n }\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = rtrim(trim($domain), '/');\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n\n // Resolve __tenant__ domains from the domains table\n if (!empty($tenantIdsForDomainLookup)) {\n $tenantIdsForDomainLookup = array_unique($tenantIdsForDomainLookup);\n $placeholders = implode(',', array_fill(0, count($tenantIdsForDomainLookup), '?'));\n $domainsQuery = $db->query(\n \"SELECT DISTINCT domain FROM domains WHERE tenant_id IN ({$placeholders})\",\n $tenantIdsForDomainLookup\n );\n\n if ($domainsQuery) {\n foreach ($domainsQuery->getResultArray() as $row) {\n $domain = rtrim(trim($row['domain']), '/');\n if (!empty($domain)) {\n $cleanDomain = preg_replace('#^https?://#', '', $domain);\n $cleanDomain = rtrim($cleanDomain, '/');\n $allowedDomains[] = 'https://' . $cleanDomain;\n $allowedDomains[] = 'http://' . $cleanDomain;\n $allowedDomains[] = $cleanDomain;\n }\n }\n }\n }\n\n // Check if origin is allowed (exact match or host-only match)\n $originAllowed = false;\n $originHost = parse_url($origin, PHP_URL_HOST);\n\n foreach ($allowedDomains as $allowed) {\n if ($origin === $allowed) {\n $originAllowed = true;\n break;\n }\n // Host-only fallback\n $allowedHost = parse_url($allowed, PHP_URL_HOST) ?: $allowed;\n if ($originHost === $allowedHost) {\n $originAllowed = true;\n break;\n }\n }\n\n if ($originAllowed || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n\n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "fc1f17493cfdfaaecc94af90fe261f9622fc4409": {"t": 1785760318.1453493, "n": 52760, "h": "03362b777d13c90a", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n // Models that can accept images. NOTE: Anthropic models are vision-capable\n // at the API level, but AnthropicProvider currently forwards text only, so\n // Claude is intentionally excluded here — that makes an image request fail\n // loudly instead of being silently stripped. Add Claude back once the\n // provider forwards image content blocks.\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4.1',\n 'gpt-4.1-mini',\n 'gpt-4-turbo',\n // Google Gemini (multimodal)\n 'gemini-2.0-flash',\n 'gemini-1.5-pro',\n 'gemini-1.5-flash',\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4o, gpt-4.1 o gemini-2.0-flash.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n // Approximate per-token rates for usage cost estimation (input pricing,\n // USD/token). These drift — verify against each provider's pricing page.\n $rates = [\n 'openai' => [\n 'gpt-4o' => 0.0000025, // $2.50 / 1M\n 'gpt-4o-mini' => 0.00000015, // $0.15 / 1M\n 'gpt-4.1' => 0.000002, // $2.00 / 1M\n 'gpt-4.1-mini' => 0.0000004, // $0.40 / 1M\n 'gpt-4-turbo' => 0.00001, // $10.00 / 1M\n 'gpt-3.5-turbo' => 0.0000005, // $0.50 / 1M\n 'default' => 0.0000025\n ],\n 'anthropic' => [\n 'claude-opus-4-8' => 0.000005, // $5.00 / 1M\n 'claude-sonnet-4-6' => 0.000003, // $3.00 / 1M\n 'claude-haiku-4-5' => 0.000001, // $1.00 / 1M\n 'claude-fable-5' => 0.00001, // $10.00 / 1M\n 'default' => 0.000003\n ],\n 'mistral' => [\n 'mistral-large-latest' => 0.000002,\n 'mistral-medium-latest' => 0.0000004,\n 'mistral-small-latest' => 0.0000002,\n 'default' => 0.000001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000027, // ~$0.27 / 1M\n 'deepseek-reasoner' => 0.00000055, // ~$0.55 / 1M\n 'default' => 0.00000027\n ],\n 'google' => [\n 'gemini-1.5-pro' => 0.00000125,\n 'gemini-1.5-flash' => 0.000000075,\n 'gemini-2.0-flash' => 0.0000001,\n 'default' => 0.00000125\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n // Remover barra final y espacios\n $domain = rtrim(trim($domain), '/');\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n\n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain, tenant_id FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n $tenantIdsForDomainLookup = [];\n\n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // If domain is __tenant__, resolve from domains table\n if ($row['domain'] === '__tenant__') {\n $tenantIdsForDomainLookup[] = $row['tenant_id'];\n continue;\n }\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = rtrim(trim($domain), '/');\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n\n // Resolve __tenant__ domains from the domains table\n if (!empty($tenantIdsForDomainLookup)) {\n $tenantIdsForDomainLookup = array_unique($tenantIdsForDomainLookup);\n $placeholders = implode(',', array_fill(0, count($tenantIdsForDomainLookup), '?'));\n $domainsQuery = $db->query(\n \"SELECT DISTINCT domain FROM domains WHERE tenant_id IN ({$placeholders})\",\n $tenantIdsForDomainLookup\n );\n\n if ($domainsQuery) {\n foreach ($domainsQuery->getResultArray() as $row) {\n $domain = rtrim(trim($row['domain']), '/');\n if (!empty($domain)) {\n $cleanDomain = preg_replace('#^https?://#', '', $domain);\n $cleanDomain = rtrim($cleanDomain, '/');\n $allowedDomains[] = 'https://' . $cleanDomain;\n $allowedDomains[] = 'http://' . $cleanDomain;\n $allowedDomains[] = $cleanDomain;\n }\n }\n }\n }\n\n // Check if origin is allowed (exact match or host-only match)\n $originAllowed = false;\n $originHost = parse_url($origin, PHP_URL_HOST);\n\n foreach ($allowedDomains as $allowed) {\n if ($origin === $allowed) {\n $originAllowed = true;\n break;\n }\n // Host-only fallback\n $allowedHost = parse_url($allowed, PHP_URL_HOST) ?: $allowed;\n if ($originHost === $allowedHost) {\n $originAllowed = true;\n break;\n }\n }\n\n if ($originAllowed || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n\n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "7c60e298d33f2d5c3daee1660a34dffc5dd0bdb5": {"t": 1785760319.6346388, "n": 52023, "h": "b8cd6e1feb3b2042", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000014, // $0.14 per 1M input tokens\n 'deepseek-coder' => 0.00000014,\n 'default' => 0.00000014\n ],\n 'google' => [\n 'gemini-1.5-pro-latest' => 0.0000035,\n 'gemini-1.0-pro' => 0.00000025,\n 'default' => 0.0000035\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n // Remover barra final y espacios\n $domain = rtrim(trim($domain), '/');\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n\n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain, tenant_id FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n $tenantIdsForDomainLookup = [];\n\n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // If domain is __tenant__, resolve from domains table\n if ($row['domain'] === '__tenant__') {\n $tenantIdsForDomainLookup[] = $row['tenant_id'];\n continue;\n }\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = rtrim(trim($domain), '/');\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n\n // Resolve __tenant__ domains from the domains table\n if (!empty($tenantIdsForDomainLookup)) {\n $tenantIdsForDomainLookup = array_unique($tenantIdsForDomainLookup);\n $placeholders = implode(',', array_fill(0, count($tenantIdsForDomainLookup), '?'));\n $domainsQuery = $db->query(\n \"SELECT DISTINCT domain FROM domains WHERE tenant_id IN ({$placeholders})\",\n $tenantIdsForDomainLookup\n );\n\n if ($domainsQuery) {\n foreach ($domainsQuery->getResultArray() as $row) {\n $domain = rtrim(trim($row['domain']), '/');\n if (!empty($domain)) {\n $cleanDomain = preg_replace('#^https?://#', '', $domain);\n $cleanDomain = rtrim($cleanDomain, '/');\n $allowedDomains[] = 'https://' . $cleanDomain;\n $allowedDomains[] = 'http://' . $cleanDomain;\n $allowedDomains[] = $cleanDomain;\n }\n }\n }\n }\n\n // Check if origin is allowed (exact match or host-only match)\n $originAllowed = false;\n $originHost = parse_url($origin, PHP_URL_HOST);\n\n foreach ($allowedDomains as $allowed) {\n if ($origin === $allowed) {\n $originAllowed = true;\n break;\n }\n // Host-only fallback\n $allowedHost = parse_url($allowed, PHP_URL_HOST) ?: $allowed;\n if ($originHost === $allowedHost) {\n $originAllowed = true;\n break;\n }\n }\n\n if ($originAllowed || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n\n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "fb48ccbe0904596f606bcbb67cbb671f1384c98a": {"t": 1785760321.059277, "n": 49881, "h": "3a596c9f477787bd", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain\n $tenant_id = $button['tenant_id'];\n $is_domain_allowed = false;\n $all_allowed_domains = [];\n\n if ($button['domain'] === '__tenant__') {\n // Button allows all tenant domains — check against the domains table\n $tenant_domains = $db->table('domains')\n ->select('domain')\n ->where('tenant_id', $tenant_id)\n ->get()\n ->getResultArray();\n\n foreach ($tenant_domains as $row) {\n $d = trim($row['domain']);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n } else {\n // Button has specific domain(s) — check only those\n $button_domains = explode(',', $button['domain']);\n foreach ($button_domains as $d) {\n $d = trim($d);\n if (!empty($d)) {\n $all_allowed_domains[] = $d;\n if ($this->_normalize_domain($d) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n }\n }\n }\n }\n\n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'tenant_id' => $tenant_id,\n 'button_domain_mode' => $button['domain'] === '__tenant__' ? 'tenant-level' : 'button-specific',\n 'allowed_domains' => array_unique($all_allowed_domains),\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000014, // $0.14 per 1M input tokens\n 'deepseek-coder' => 0.00000014,\n 'default' => 0.00000014\n ],\n 'google' => [\n 'gemini-1.5-pro-latest' => 0.0000035,\n 'gemini-1.0-pro' => 0.00000025,\n 'default' => 0.0000035\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "20ab8d7a951db591181bcaaf38d41f35db0ac8a0": {"t": 1785760322.5602095, "n": 48992, "h": "46148ffd17d61d00", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 1000000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n // Use log_message directly to avoid issues during streaming\n log_message('error', '[USAGE] Excepción al insertar log | Error: ' . $e->getMessage() . ' | Trace: ' . substr($e->getTraceAsString(), 0, 500) . ' | Data: ' . json_encode($data ?? []));\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'deepseek' => [\n 'deepseek-chat' => 0.00000014, // $0.14 per 1M input tokens\n 'deepseek-coder' => 0.00000014,\n 'default' => 0.00000014\n ],\n 'google' => [\n 'gemini-1.5-pro-latest' => 0.0000035,\n 'gemini-1.0-pro' => 0.00000025,\n 'default' => 0.0000035\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n\n // Fix: if provider_rates is a scalar (float), treat it as the rate directly\n if (is_array($provider_rates)) {\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n } else {\n $rate = $provider_rates;\n }\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "34fb6fd077e332565b55498893aee409e063ec76": {"t": 1785760324.070907, "n": 48308, "h": "ec6a0b16a71926f7", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "8b46d2429d37247eaaa4691949362c75887a065e": {"t": 1785760325.7275314, "n": 48380, "h": "14ebc46173d1213c", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get button configuration to find the assigned API key\n $button_api_key_id = null;\n if ($button_id) {\n $buttonsModel = new \\App\\Models\\ButtonsModel();\n $button = $buttonsModel->where('button_id', $button_id)\n ->where('tenant_id', $tenant_id)\n ->first();\n \n if ($button && !empty($button['api_key_id'])) {\n $button_api_key_id = $button['api_key_id'];\n log_message('error', '[PROXY] CHECKPOINT 3a: Found button API key ID: ' . $button_api_key_id);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 3b: No API key assigned to button, will use default');\n }\n }\n \n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id, $button_api_key_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null, $button_api_key_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'button_api_key_id' => $button_api_key_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n // First try to get the specific API key assigned to the button\n if ($button_api_key_id) {\n log_message('error', '[PROXY] CHECKPOINT 7a: Trying to get specific API key for button: ' . $button_api_key_id);\n $apiKeyRecord = $apiKeysModel->where('api_key_id', $button_api_key_id)\n ->where('tenant_id', $tenant_id)\n ->where('active', 1)\n ->first();\n \n if ($apiKeyRecord) {\n log_message('error', '[PROXY] CHECKPOINT 7b: Button-specific API key found: ' . $apiKeyRecord['name']);\n } else {\n log_message('error', '[PROXY] CHECKPOINT 7c: Button-specific API key not found, falling back to default');\n }\n }\n \n // Fallback to default API key if no specific key found\n if (!$apiKeyRecord) {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n log_message('error', '[PROXY] CHECKPOINT 7d: Default API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n }\n \n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'button_api_key_id' => $button_api_key_id,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "9ced333a70994ebfcb1a1d71488877a0873a0d5f": {"t": 1785760327.4144793, "n": 46270, "h": "1ac075cda1b8fe3d", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Check quota before processing\n $quotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n if (!$quotaCheck['allowed']) {\n throw new \\Exception($quotaCheck['message']);\n }\n \n // Store quota info for response\n $quotaInfo = $quotaCheck;\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Update quota info after processing\n $finalQuotaCheck = $this->_check_user_quota($api_user, $button['tenant_id']);\n \n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out'],\n 'quota' => [\n 'monthly_remaining' => $finalQuotaCheck['monthly_remaining'] ?? 0,\n 'daily_remaining' => $finalQuotaCheck['daily_remaining'] ?? 0,\n 'monthly_used' => $finalQuotaCheck['monthly_used'] ?? 0,\n 'daily_used' => $finalQuotaCheck['daily_used'] ?? 0\n ]\n ]);\n }\n } catch (\\Exception $e) {\n $errorMessage = $e->getMessage();\n $statusCode = 500;\n \n // Check if it's a quota error\n if (strpos($errorMessage, 'quota exceeded') !== false) {\n $statusCode = 429; // Too Many Requests\n }\n \n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $errorMessage,\n 'status_code' => $statusCode,\n 'provider' => $provider ?? 'unknown',\n 'model' => $model ?? 'unknown'\n ]);\n\n return $this->response\n ->setStatusCode($statusCode)\n ->setJSON([\n 'success' => false,\n 'error' => $errorMessage,\n 'error_type' => strpos($errorMessage, 'quota exceeded') !== false ? 'quota_exceeded' : 'server_error'\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n\n /**\n * Check if user has available quota for requests\n */\n private function _check_user_quota($api_user, $tenant_id)\n {\n $db = db_connect();\n \n // Get current month usage\n $firstDayOfMonth = date('Y-m-01 00:00:00');\n $monthlyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $firstDayOfMonth)\n ->get()\n ->getRowArray();\n \n $currentMonthlyUsage = (int)($monthlyUsage['tokens'] ?? 0);\n $monthlyQuota = (int)($api_user['quota'] ?? 10000);\n \n // Get current day usage\n $today = date('Y-m-d 00:00:00');\n $dailyUsage = $db->table('usage_logs')\n ->selectSum('tokens')\n ->where('tenant_id', $tenant_id)\n ->where('external_id', $api_user['external_id'])\n ->where('created_at >=', $today)\n ->get()\n ->getRowArray();\n \n $currentDailyUsage = (int)($dailyUsage['tokens'] ?? 0);\n $dailyQuota = (int)($api_user['daily_quota'] ?? 10000);\n \n // Check monthly quota\n if ($currentMonthlyUsage >= $monthlyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Monthly quota exceeded. Used: {$currentMonthlyUsage}/{$monthlyQuota} tokens. Quota resets on \" . date('Y-m-01', strtotime('+1 month')),\n 'quota_type' => 'monthly',\n 'used' => $currentMonthlyUsage,\n 'limit' => $monthlyQuota\n ];\n }\n \n // Check daily quota\n if ($currentDailyUsage >= $dailyQuota) {\n return [\n 'allowed' => false,\n 'message' => \"Daily quota exceeded. Used: {$currentDailyUsage}/{$dailyQuota} tokens. Quota resets tomorrow.\",\n 'quota_type' => 'daily',\n 'used' => $currentDailyUsage,\n 'limit' => $dailyQuota\n ];\n }\n \n return [\n 'allowed' => true,\n 'monthly_remaining' => $monthlyQuota - $currentMonthlyUsage,\n 'daily_remaining' => $dailyQuota - $currentDailyUsage,\n 'monthly_used' => $currentMonthlyUsage,\n 'daily_used' => $currentDailyUsage\n ];\n }\n}\n"}, "c57e8870d6e44eb64e231b21a7d4483e6f5d96d1": {"t": 1785760329.4500656, "n": 42485, "h": "ef12791cf2eb6b02", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => 0, // Set to 0 since column is NOT NULL but we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "2f85797d2d5bd48344934a4d757b9c3193e64813": {"t": 1785760331.0922775, "n": 42468, "h": "b3ee6b7d123fe612", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => null, // Set to null since we use external_id for identification\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => (int)$total_tokens, // Ensure it's an integer\n 'cost' => (float)($cost ?? 0),\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "edd3aa55ed30f114f9968885b579b1a859fb965d": {"t": 1785760333.1291661, "n": 42377, "h": "d8ab9d57f140abad", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_message('error', '[USAGE] Intentando insertar log de uso | Data: ' . json_encode($data));\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n log_message('error', '[USAGE] Insert result: ' . print_r($result, true));\n\n if (!$result) {\n $modelErrors = $usageModel->errors();\n $error = $db->error();\n log_message('error', '[USAGE] Error al insertar log | DB Error: ' . json_encode($error) . ' | Model Errors: ' . json_encode($modelErrors) . ' | Data: ' . json_encode($data));\n return false;\n } else {\n log_message('error', '[USAGE] Log de uso insertado exitosamente con ID: ' . $result);\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "3ef33b340bd21391251dc270ab61178f036290c0": {"t": 1785760334.733964, "n": 42158, "h": "13e2148450717845", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n log_message('info', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando el modelo\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n \n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000\n ];\n \n $insertResult = $apiUsersModel->insert($userData);\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | Errors: ' . json_encode($modelErrors) . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . json_encode($modelErrors));\n }\n \n log_message('info', '[PROXY] User created successfully with user_id: ' . $insertResult);\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "5dbbafdb5d263c6b8b923a9d073a67d59e3dcbe5": {"t": 1785760336.246102, "n": 43114, "h": "1cbb625fbd1f55bc", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n log_message('error', '[PROXY] API user not found, checking auto_create_api_users setting | Button auto_create: ' . ($button['auto_create_api_users'] ?? 'NULL') . ' | External ID: ' . $external_id);\n \n if (!empty($button['auto_create_api_users'])) {\n log_message('error', '[PROXY] AUTO-CREATE ENABLED - USING DIRECT INSERT VERSION 2.0 - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando inserción directa\n // Generate unique user_id\n $user_id = 'usr-' . bin2hex(random_bytes(8)) . '-' . bin2hex(random_bytes(4));\n \n $userData = [\n 'user_id' => $user_id,\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000,\n 'daily_quota' => 10000,\n 'created_at' => date('Y-m-d H:i:s'),\n 'updated_at' => date('Y-m-d H:i:s')\n ];\n \n log_message('error', '[PROXY] About to insert user directly with data: ' . json_encode($userData));\n \n // Insert directly into database to bypass model validation\n $insertResult = $db->table('api_users')->insert($userData);\n \n log_message('error', '[PROXY] Direct insert result: ' . print_r($insertResult, true));\n \n if (!$insertResult) {\n log_message('error', '[PROXY] Error al crear usuario API automáticamente con inserción directa | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente con inserción directa');\n }\n \n log_message('error', '[PROXY] User inserted successfully! Checking if user can be found...');\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n } else {\n log_message('error', '[PROXY] Auto-create is NOT enabled for this button');\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "310397434428fd07135f100fe3b35757a92db675": {"t": 1785760338.117933, "n": 43080, "h": "c17434f906d5d4f1", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n log_message('error', '[PROXY] API user not found, checking auto_create_api_users setting | Button auto_create: ' . ($button['auto_create_api_users'] ?? 'NULL') . ' | External ID: ' . $external_id);\n \n if (!empty($button['auto_create_api_users'])) {\n log_message('error', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente usando inserción directa\n // Generate unique user_id\n $user_id = 'usr-' . bin2hex(random_bytes(8)) . '-' . bin2hex(random_bytes(4));\n \n $userData = [\n 'user_id' => $user_id,\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000,\n 'daily_quota' => 10000,\n 'created_at' => date('Y-m-d H:i:s'),\n 'updated_at' => date('Y-m-d H:i:s')\n ];\n \n log_message('error', '[PROXY] About to insert user directly with data: ' . json_encode($userData));\n \n // Insert directly into database to bypass model validation\n $insertResult = $db->table('api_users')->insert($userData);\n \n log_message('error', '[PROXY] Direct insert result: ' . print_r($insertResult, true));\n \n if (!$insertResult) {\n log_message('error', '[PROXY] Error al crear usuario API automáticamente con inserción directa | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente con inserción directa');\n }\n \n log_message('error', '[PROXY] User inserted successfully! Checking if user can be found...');\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n } else {\n log_message('error', '[PROXY] Auto-create is NOT enabled for this button');\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "3048b4dde084642144f39e711cb4c641e2881701": {"t": 1785760339.6950448, "n": 43404, "h": "a6ddf9dffcc65cb5", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n log_message('error', '[PROXY] API user not found, checking auto_create_api_users setting | Button auto_create: ' . ($button['auto_create_api_users'] ?? 'NULL') . ' | External ID: ' . $external_id);\n \n if (!empty($button['auto_create_api_users'])) {\n log_message('error', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n // Generate unique user_id\n $user_id = 'usr-' . bin2hex(random_bytes(8)) . '-' . bin2hex(random_bytes(4));\n \n $userData = [\n 'user_id' => $user_id,\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n \n log_message('error', '[PROXY] About to insert user with data: ' . json_encode($userData));\n \n // Temporarily disable validation for auto-creation\n $insertResult = $apiUsersModel->insert($userData, false);\n \n log_message('error', '[PROXY] Insert result: ' . print_r($insertResult, true));\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . $errorsJson);\n }\n \n log_message('error', '[PROXY] User inserted successfully! Checking if user can be found...');\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n } else {\n log_message('error', '[PROXY] Auto-create is NOT enabled for this button');\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "ac643a729f3dffad5ee2e39b1dbf45021e5f06f4": {"t": 1785760341.376507, "n": 43325, "h": "85a4b5e18414da3f", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n log_message('error', '[PROXY] API user not found, checking auto_create_api_users setting | Button auto_create: ' . ($button['auto_create_api_users'] ?? 'NULL') . ' | External ID: ' . $external_id);\n \n if (!empty($button['auto_create_api_users'])) {\n log_message('error', '[PROXY] AUTO-CREATE ENABLED - Starting auto-creation process for external_id: ' . $external_id);\n \n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n // Generate unique user_id\n $user_id = 'usr-' . bin2hex(random_bytes(8)) . '-' . bin2hex(random_bytes(4));\n \n $userData = [\n 'user_id' => $user_id,\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n \n log_message('error', '[PROXY] About to insert user with data: ' . json_encode($userData));\n \n $insertResult = $apiUsersModel->insert($userData);\n \n log_message('error', '[PROXY] Insert result: ' . print_r($insertResult, true));\n \n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente: ' . $errorsJson);\n }\n \n log_message('error', '[PROXY] User inserted successfully! Checking if user can be found...');\n \n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n } else {\n log_message('error', '[PROXY] Auto-create is NOT enabled for this button');\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "d5827eb35d5a94c744db34e23189ac7814c6b321": {"t": 1785760342.9234312, "n": 42346, "h": "cdb2aef4ab029559", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n // Generate unique user_id\n $user_id = 'usr-' . bin2hex(random_bytes(8)) . '-' . bin2hex(random_bytes(4));\n \n $userData = [\n 'user_id' => $user_id,\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "5de31afee57feefc3be512358d449f8f17b1f541": {"t": 1785760344.274831, "n": 42180, "h": "bc9ae9598628f18b", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $raw_api_key = $apiKeyRecord['api_key'];\n \n // Manual decryption since afterFind is commented out\n if (strlen($raw_api_key) > 100) { // Likely encrypted\n try {\n $encrypter = \\Config\\Services::encrypter();\n $api_key = $encrypter->decrypt(base64_decode($raw_api_key));\n log_message('error', '[PROXY] CHECKPOINT 8a: API key decrypted manually. Original length: ' . strlen($raw_api_key) . ', decrypted length: ' . strlen($api_key));\n } catch (\\Exception $e) {\n log_message('error', '[PROXY] ERROR: Failed to decrypt API key: ' . $e->getMessage());\n $api_key = $raw_api_key; // Use as-is if decryption fails\n }\n } else {\n $api_key = $raw_api_key; // Already decrypted or plain text\n }\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "572cf34536a74461b3e7ece5a60169948cb73631": {"t": 1785760345.8386896, "n": 41324, "h": "661cf2e4824cc748", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n \n log_message('error', '[PROXY] CHECKPOINT 8: API key extracted. Length: ' . strlen($api_key) . ', starts with: ' . substr($api_key, 0, 10));\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_message('error', '[PROXY] CHECKPOINT 9: About to create provider instance. Provider: ' . $provider . ', API key length: ' . strlen($api_key));\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "46e80dcdc629a4a678ba53c28c89f55b5ad3b086": {"t": 1785760347.5371902, "n": 41711, "h": "7bdf0cca4a056a2a", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_message('error', '[PROXY] CHECKPOINT 5: About to get API key from database for tenant: ' . $tenant_id . ', provider: ' . $provider);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n log_message('error', '[PROXY] CHECKPOINT 6: ApiKeysModel created successfully');\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_message('error', '[PROXY] CHECKPOINT 7: API key lookup completed. Found: ' . (!empty($apiKeyRecord) ? 'YES' : 'NO'));\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "9e57b93fb9bd89ef7fa1c874e7c0fb672c56a7a2": {"t": 1785760349.8429048, "n": 42317, "h": "d6696dc5f03739bd", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n log_error('PROXY', 'CHECKPOINT 4: Entering _get_llm_provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_debug('PROXY', 'Attempting to get API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider\n ]);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'api_key_starts_with' => !empty($apiKeyRecord['api_key']) ? substr($apiKeyRecord['api_key'], 0, 10) : 'none',\n 'looks_encrypted' => !empty($apiKeyRecord['api_key']) && strlen($apiKeyRecord['api_key']) > 50 ? 'yes' : 'no',\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "f7fdaf05db5b5502935dc609edbe57aba5111331": {"t": 1785760351.345582, "n": 42148, "h": "9c62defabdc3e174", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_error('PROXY', 'CHECKPOINT 1: After parameter extraction');\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n log_error('PROXY', 'CHECKPOINT 2: After parameter validation');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n log_error('PROXY', 'CHECKPOINT 3: About to process LLM request', [\n 'provider' => $provider,\n 'model' => $model,\n 'tenant_id' => $button['tenant_id']\n ]);\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_debug('PROXY', 'Attempting to get API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider\n ]);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'api_key_starts_with' => !empty($apiKeyRecord['api_key']) ? substr($apiKeyRecord['api_key'], 0, 10) : 'none',\n 'looks_encrypted' => !empty($apiKeyRecord['api_key']) && strlen($apiKeyRecord['api_key']) > 50 ? 'yes' : 'no',\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "d79fa2bfddc5ec21ebc51916efdc00ab6bc764a4": {"t": 1785760363.5154455, "n": 41771, "h": "b8195f6a98365e2d", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n log_debug('PROXY', 'Attempting to get API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider\n ]);\n \n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n \n try {\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'api_key_starts_with' => !empty($apiKeyRecord['api_key']) ? substr($apiKeyRecord['api_key'], 0, 10) : 'none',\n 'looks_encrypted' => !empty($apiKeyRecord['api_key']) && strlen($apiKeyRecord['api_key']) > 50 ? 'yes' : 'no',\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error getting API key from database', [\n 'tenant_id' => $tenant_id,\n 'provider' => $provider,\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n throw $e;\n }\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No default API key found for provider, checking for any active key', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id\n ]);\n \n // Fallback: try to get any active API key for this provider and tenant\n $anyActiveKey = $apiKeysModel->where('tenant_id', $tenant_id)\n ->where('provider', $provider)\n ->where('active', 1)\n ->first();\n \n if ($anyActiveKey && !empty($anyActiveKey['api_key'])) {\n $api_key = $anyActiveKey['api_key'];\n log_info('PROXY', 'Using any active API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $anyActiveKey['name'] ?? 'Unknown',\n 'api_key_id' => $anyActiveKey['api_key_id'] ?? 'Unknown',\n 'is_default' => $anyActiveKey['is_default'] ?? 'Unknown'\n ]);\n } else {\n // Debug: show all available keys for this tenant\n $allKeys = $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll();\n log_warning('PROXY', 'No API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'all_active_keys_count' => count($allKeys),\n 'all_active_keys' => array_map(function($key) {\n return [\n 'api_key_id' => $key['api_key_id'],\n 'provider' => $key['provider'],\n 'name' => $key['name'],\n 'is_default' => $key['is_default']\n ];\n }, $allKeys)\n ]);\n }\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "f38671ca1f7f99159631feeb58e84e9dd1e5f6ea": {"t": 1785760364.9496953, "n": 39088, "h": "eebf33edfa698d0c", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n log_debug('PROXY', 'About to validate parameters and get button configuration', [\n 'request_id' => $request_id,\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'missing'\n ]);\n\n // Validate required parameters\n log_debug('PROXY', 'Validating required parameters', [\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n if (!$provider) {\n log_error('PROXY', 'Missing provider parameter');\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n log_error('PROXY', 'Missing model or messages', [\n 'model' => $model,\n 'messages_count' => count($messages)\n ]);\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n log_error('PROXY', 'Missing external_id parameter');\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n log_error('PROXY', 'Missing button_id parameter');\n throw new \\Exception('Missing button_id in request');\n }\n\n log_debug('PROXY', 'All required parameters validated successfully');\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "e9e1d9fba2bcaafe0fe7bef5f18d4f8234d127b7": {"t": 1785760366.8467162, "n": 38059, "h": "3f0f95968e7e6cd2", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n \n // Debug: Check all buttons for this tenant\n $allButtonsQuery = $db->query(\"SELECT button_id, status, provider, model FROM buttons WHERE tenant_id = ?\", [$json->tenantId ?? 'unknown']);\n $allButtons = $allButtonsQuery ? $allButtonsQuery->getResultArray() : [];\n \n log_debug('PROXY', 'All buttons for tenant', [\n 'tenant_id' => $json->tenantId ?? 'unknown',\n 'requested_button_id' => $button_id,\n 'all_buttons' => $allButtons\n ]);\n \n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id,\n 'tenant_id' => $json->tenantId ?? 'unknown'\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n // Validate required parameters\n if (!$provider) {\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n throw new \\Exception('Missing button_id in request');\n }\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "18ebaaa206dc9c73bf62738b5459348597fe2b48": {"t": 1785760371.292402, "n": 37330, "h": "add9d4bc30b7c95e", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n // Validate required parameters\n if (!$provider) {\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n throw new \\Exception('Missing button_id in request');\n }\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n log_debug('PROXY', 'Final API key being used for provider', [\n 'provider' => $provider,\n 'api_key_prefix' => substr($api_key, 0, 10),\n 'api_key_suffix' => substr($api_key, -4),\n 'api_key_length' => strlen($api_key),\n 'is_base64_like' => preg_match('/^[A-Za-z0-9+\\/]*={0,2}$/', $api_key) ? 'yes' : 'no'\n ]);\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "044934bcf717b51c02ec3879e2545d52b0cc5ae8": {"t": 1785760373.4781492, "n": 36952, "h": "40847207448dea05", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n // Validate required parameters\n if (!$provider) {\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n throw new \\Exception('Missing button_id in request');\n }\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success'\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n // Use model instead of direct insert to handle timestamps properly\n $usageModel = new \\App\\Models\\UsageLogsModel();\n $result = $usageModel->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "a724423a5627190120c370d84e41ea2d2e7dac30": {"t": 1785760375.028391, "n": 36878, "h": "a842521a43c1f352", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n // Validate required parameters\n if (!$provider) {\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n throw new \\Exception('Missing button_id in request');\n }\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success',\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n $result = $db->table('usage_logs')->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}, "83847af694dcd368144f8a396f032ff3b9c6eb6e": {"t": 1785760376.550217, "n": 36931, "h": "55c724cb9db19f02", "c": "<?php\n\nnamespace App\\Controllers;\n\nuse CodeIgniter\\Controller;\nuse CodeIgniter\\HTTP\\ResponseInterface;\nuse App\\Models\\LlmProxyModel;\nuse App\\Models\\UsageLogsModel;\nuse App\\Libraries\\LlmProviders\\OpenAiProvider;\nuse App\\Libraries\\LlmProviders\\AnthropicProvider;\nuse App\\Libraries\\LlmProviders\\MistralProvider;\nuse App\\Libraries\\LlmProviders\\DeepseekProvider;\nuse App\\Libraries\\LlmProviders\\GoogleProvider;\nuse App\\Libraries\\LlmProviders\\AzureProvider;\n\nclass LlmProxy extends Controller\n{\n /**\n * Configuración del proxy\n */\n private $api_keys = [];\n private $endpoints = [];\n private $use_simulated_responses = false;\n private $allowed_origins = '*';\n\n protected $llm_proxy_model;\n\n /**\n * Constructor\n */\n public function __construct()\n {\n // Load necessary helpers\n helper(['url', 'form', 'logger', 'jwt', 'api_key', 'image_proxy', 'hash']);\n\n // Initialize proxy model\n $this->llm_proxy_model = new LlmProxyModel();\n\n // Initialize proxy configuration\n $this->_initialize_config();\n\n // Log initialization with detailed context\n log_info('PROXY', 'Proxy initialized', [\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod(),\n 'user_agent' => service('request')->getUserAgent()->getAgentString(),\n 'request_id' => uniqid('req_'),\n 'timestamp' => date('Y-m-d H:i:s'),\n 'environment' => ENVIRONMENT,\n 'providers_configured' => array_filter($this->api_keys, fn($key) => !empty($key))\n ]);\n }\n\n /**\n * Main endpoint for proxy requests\n */\n public function index()\n {\n $request_id = uniqid('proxy_');\n log_info('PROXY', 'Request received at main endpoint', [\n 'request_id' => $request_id,\n 'ip' => service('request')->getIPAddress(),\n 'method' => service('request')->getMethod()\n ]);\n\n // Ensure CORS headers are set for all responses\n $this->_set_cors_headers();\n\n // Verify this is a POST request\n if (service('request')->getMethod() !== 'post') {\n log_error('PROXY', 'Method not allowed', [\n 'request_id' => $request_id,\n 'method' => service('request')->getMethod()\n ]);\n\n return $this->response\n ->setContentType('application/json')\n ->setStatusCode(405)\n ->setJSON(['error' => ['message' => 'Method not allowed']]);\n }\n\n try {\n // Get request data\n $raw_input = file_get_contents('php://input');\n log_message('error', '[PROXY] Raw request data: ' . print_r([\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type'),\n 'method' => $this->request->getMethod(),\n 'headers' => $this->request->headers()\n ], true));\n\n $json = $this->request->getJSON();\n if (!$json) {\n throw new \\Exception('Invalid request format');\n }\n\n log_debug('PROXY', 'Request data received', [\n 'request_id' => $request_id,\n 'json' => json_encode($json),\n 'raw_input' => $raw_input,\n 'content_type' => $this->request->getHeaderLine('Content-Type')\n ]);\n\n // Extract request parameters\n $messages = $json->messages ?? [];\n $options = $json->options ?? [];\n $stream = $json->stream ?? false;\n $external_id = $json->userId ?? $json->user_id ?? null; // Aceptar tanto userId como user_id\n $button_id = $json->buttonId ?? $json->button_id ?? null; // Aceptar tanto buttonId como button_id\n\n // Get provider and model from button configuration in database\n $provider = $json->provider ?? 'openai'; // Fallback por compatibilidad\n $model = $json->model ?? null; // Fallback por compatibilidad\n \n if ($button_id) {\n try {\n $db = \\Config\\Database::connect();\n $buttonQuery = $db->query(\"\n SELECT provider, model, api_key_id \n FROM buttons \n WHERE button_id = ? AND status = 'active'\n \", [$button_id]);\n \n if ($buttonQuery && $buttonRow = $buttonQuery->getRowArray()) {\n $provider = $buttonRow['provider'];\n $model = $buttonRow['model'];\n \n log_debug('PROXY', 'Button configuration loaded from database', [\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'api_key_id' => $buttonRow['api_key_id']\n ]);\n } else {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Button not found or inactive: ' . $button_id);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error loading button configuration', [\n 'button_id' => $button_id,\n 'error' => $e->getMessage()\n ]);\n throw new \\Exception('Error loading button configuration: ' . $e->getMessage());\n }\n }\n\n // --- SOPORTE PARA NUEVO PAYLOAD: prompt, content, context ---\n if ((empty($messages) || count($messages) === 0) && (isset($json->prompt) || isset($json->content) || isset($json->context))) {\n $user_content = '';\n if (isset($json->context) && $json->context) {\n $user_content .= trim($json->context) . \"\\n\";\n }\n if (isset($json->content) && $json->content) {\n $user_content .= trim($json->content) . \"\\n\";\n }\n if (isset($json->prompt) && $json->prompt) {\n $user_content .= trim($json->prompt);\n }\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => trim($user_content)\n ];\n }\n // --- FIN SOPORTE NUEVO PAYLOAD ---\n\n // --- FLEXIBILIDAD DE TEMPERATURE ---\n // Permitir temperature en raíz o en options\n if (isset($json->temperature)) {\n $options = (array)$options;\n $options['temperature'] = $json->temperature;\n }\n // --- FIN FLEXIBILIDAD TEMPERATURE ---\n\n // --- FLEXIBILIDAD SYSTEM PROMPT ---\n if (isset($json->systemPrompt)) {\n $has_system = false;\n foreach ($messages as $msg) {\n if (isset($msg->role) && $msg->role === 'system') {\n $has_system = true;\n break;\n }\n }\n if (!$has_system) {\n // Insertar systemPrompt como primer mensaje system\n array_unshift($messages, (object)[\n 'role' => 'system',\n 'content' => $json->systemPrompt\n ]);\n }\n }\n // --- FIN FLEXIBILIDAD SYSTEM PROMPT ---\n\n // --- FLEXIBILIDAD IMAGEN ---\n if (isset($json->image) && $json->image) {\n $has_image_in_messages = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if ((is_object($part) && isset($part->type) && $part->type === 'image_url') ||\n (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part)))) {\n $has_image_in_messages = true;\n break 2;\n }\n }\n }\n }\n if (!$has_image_in_messages) {\n // Insertar imagen en el primer mensaje de usuario, o crear uno\n $image_part = null;\n if (is_string($json->image)) {\n if (preg_match('/^data:image\\//', $json->image)) {\n // Base64\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n } elseif (preg_match('/^https?:\\/\\//', $json->image)) {\n // URL\n $image_part = (object)[ 'type' => 'image_url', 'image_url' => $json->image ];\n }\n }\n if ($image_part) {\n // Buscar primer mensaje de usuario\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el content ya es array, agregar la imagen\n if (is_array($msg->content)) {\n $msg->content[] = $image_part;\n } else {\n // Si el content es string, convertirlo en array multimodal\n $msg->content = [$msg->content, $image_part];\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => [$image_part]\n ];\n }\n }\n }\n }\n // --- FIN FLEXIBILIDAD IMAGEN ---\n\n // --- ANTEPONER CONTEXTO AL PRIMER MENSAJE DE USUARIO ---\n if (isset($json->context) && $json->context) {\n $context = trim($json->context);\n $user_found = false;\n foreach ($messages as &$msg) {\n if (isset($msg->role) && $msg->role === 'user') {\n // Si el contenido es array multimodal\n if (is_array($msg->content)) {\n // Anteponer el contexto como string al inicio del array\n array_unshift($msg->content, $context);\n } else {\n // Si el contenido es string, anteponer el contexto\n $msg->content = $context . \"\\n\" . $msg->content;\n }\n $user_found = true;\n break;\n }\n }\n unset($msg);\n // Si no hay mensaje de usuario, crear uno\n if (!$user_found) {\n $messages[] = (object)[\n 'role' => 'user',\n 'content' => $context\n ];\n }\n }\n // --- FIN ANTEPONER CONTEXTO ---\n\n // --- VALIDACIÓN DE IMÁGENES Y MODELOS MULTIMODAL ---\n $multimodal_models = [\n // OpenAI\n 'gpt-4o',\n 'gpt-4-vision-preview',\n 'gpt-4-vision',\n 'gpt-4-turbo-vision',\n // Google Gemini (multimodal)\n 'gemini-1.5-pro-latest',\n 'gemini-1.0-pro',\n 'gemini-1.0-pro-vision',\n // Anthropic Claude 3 (multimodal)\n 'claude-3-opus-20240229',\n 'claude-3-sonnet-20240229',\n 'claude-3-haiku-20240307',\n 'claude-3-7-sonnet-20250219',\n // Futuro: Mistral (si lanzan multimodal), otros proveedores...\n ];\n $has_image = false;\n foreach ($messages as $msg) {\n if (isset($msg->content) && is_array($msg->content)) {\n foreach ($msg->content as $part) {\n if (is_object($part) && isset($part->type) && $part->type === 'image_url') {\n $has_image = true;\n break 2;\n }\n // Opcional: detectar base64/URL\n if (is_string($part) && (preg_match('/^data:image\\//', $part) || preg_match('/\\.(jpg|jpeg|png|gif|webp)$/i', $part))) {\n $has_image = true;\n break 2;\n }\n }\n }\n }\n if ($has_image && (!in_array(strtolower($model), $multimodal_models))) {\n return $this->response->setStatusCode(400)->setJSON([\n 'success' => false,\n 'error' => 'El modelo o proveedor seleccionado no soporta imágenes. Usa un modelo multimodal como gpt-4-vision-preview, gpt-4o o gemini-1.0-pro-vision.'\n ]);\n }\n // --- FIN VALIDACIÓN DE IMÁGENES ---\n\n log_message('error', '[PROXY] Extracted parameters: ' . print_r([\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'json' => json_encode($json)\n ], true));\n\n log_debug('PROXY', 'Extracted parameters', [\n 'request_id' => $request_id,\n 'provider' => $provider,\n 'model' => $model,\n 'messages_count' => count($messages),\n 'stream' => $stream,\n 'external_id' => $external_id,\n 'button_id' => $button_id\n ]);\n\n // Validate required parameters\n if (!$provider) {\n throw new \\Exception('Missing required parameter: provider is required');\n }\n \n if (!$model || empty($messages)) {\n throw new \\Exception('Missing required parameters: model and messages are required');\n }\n\n if (!$external_id) {\n throw new \\Exception('Missing user_id in request');\n }\n\n if (!$button_id) {\n throw new \\Exception('Missing button_id in request');\n }\n\n // Get domain from headers\n $domain = $this->_extract_domain_from_headers();\n log_debug('PROXY', 'Domain extracted', [\n 'domain' => $domain,\n 'request_id' => $request_id\n ]);\n\n // Get button configuration\n $db = db_connect();\n $button = $db->table('buttons')\n ->where('button_id', $button_id)\n ->where('status', 'active')\n ->get()\n ->getRowArray();\n\n if (!$button) {\n log_error('PROXY', 'Button not found or inactive', [\n 'button_id' => $button_id\n ]);\n throw new \\Exception('Invalid or inactive button');\n }\n\n // Store the actual button_id from database\n $actual_button_id = $button['button_id'];\n\n // Validate domain - support multiple domains separated by commas\n if ($button['domain'] !== '*') {\n $allowed_domains = explode(',', $button['domain']);\n $is_domain_allowed = false;\n \n foreach ($allowed_domains as $allowed_domain) {\n $allowed_domain = trim($allowed_domain);\n if ($this->_normalize_domain($allowed_domain) === $this->_normalize_domain($domain)) {\n $is_domain_allowed = true;\n break;\n }\n }\n \n if (!$is_domain_allowed) {\n log_error('PROXY', 'Domain mismatch', [\n 'request_domain' => $domain,\n 'normalized_request_domain' => $this->_normalize_domain($domain),\n 'button_domain' => $button['domain'],\n 'allowed_domains' => $allowed_domains,\n 'origin' => service('request')->getHeaderLine('Origin'),\n 'referer' => service('request')->getHeaderLine('Referer'),\n 'button' => $button\n ]);\n throw new \\Exception('Invalid domain for this button');\n }\n }\n\n // Get API user\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n\n // Si no existe, intentar crear si el botón lo permite\n if (!$api_user) {\n if (!empty($button['auto_create_api_users'])) {\n // Crear usuario API en caliente\n $apiUsersModel = new \\App\\Models\\ApiUsersModel();\n $userData = [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id,\n 'name' => $external_id,\n 'active' => 1,\n 'quota' => 10000, // Fix: requerido por el modelo\n // 'created_at' y 'updated_at' se manejan por el modelo\n ];\n $insertResult = $apiUsersModel->insert($userData);\n if (!$insertResult) {\n $modelErrors = $apiUsersModel->errors();\n $insertResultStr = print_r($insertResult, true);\n $errorsJson = json_encode($modelErrors);\n $errorsPrintR = print_r($modelErrors, true);\n log_message('error', '[PROXY] Error al crear usuario API automáticamente | InsertResult: ' . $insertResultStr . ' | Errors(json): ' . $errorsJson . ' | Errors(print_r): ' . $errorsPrintR . ' | UserData: ' . json_encode($userData));\n throw new \\Exception('No se pudo crear el usuario API automáticamente');\n }\n $api_user = $db->table('api_users')\n ->where('tenant_id', $button['tenant_id'])\n ->where('external_id', $external_id)\n ->where('active', 1)\n ->get()\n ->getRowArray();\n log_message('info', '[PROXY] API user auto-created for external_id=' . $external_id . ' en tenant=' . $button['tenant_id']);\n }\n }\n\n if (!$api_user) {\n log_error('PROXY', 'API user not found or inactive', [\n 'tenant_id' => $button['tenant_id'],\n 'external_id' => $external_id\n ]);\n throw new \\Exception('Invalid or inactive API user');\n }\n\n // Use button configuration\n $provider = $button['provider'];\n $model = $button['model'];\n\n // Process the request\n $response = $this->_process_llm_request(\n $provider,\n $model,\n $messages,\n $options->temperature ?? 0.7,\n $stream,\n $button['tenant_id'],\n $external_id,\n $actual_button_id\n );\n\n return $response;\n } catch (\\Exception $e) {\n // Log error\n log_message('error', 'Error processing LLM request: ' . $e->getMessage(), [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n\n // Return error response\n return $this->response->setStatusCode(500)->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Procesa la solicitud al LLM\n */\n private function _process_llm_request($provider, $model, $messages, $temperature, $stream, $tenant_id, $external_id, $button_id = null, $has_image = false)\n {\n try {\n // Get LLM provider instance\n $llm = $this->_get_llm_provider($provider, $tenant_id);\n\n // Set options\n $options = [\n 'temperature' => $temperature,\n 'stream' => $stream\n ];\n\n // Process request\n if ($stream) {\n log_info('PROXY', 'Processing streaming request', [\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n // Set CORS headers first for streaming\n $this->_set_cors_headers();\n \n // Set headers for streaming\n header('Content-Type: text/event-stream');\n header('Cache-Control: no-cache');\n header('Connection: keep-alive');\n header('X-Accel-Buffering: no');\n\n // Log usage before streaming starts (approximate token count)\n $usage = $llm->get_token_usage($messages);\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $usage['tokens_in'] + $usage['tokens_out'], null, $has_image);\n\n // Get streaming response\n $response = $llm->process_stream_request($model, $messages, $options);\n\n // Process stream response\n if (is_callable($response)) {\n log_message('debug', '[PROXY] Starting streaming response execution');\n $response();\n log_message('debug', '[PROXY] Streaming response completed');\n } else {\n log_error('PROXY', 'Invalid stream response', [\n 'provider' => $provider,\n 'type' => gettype($response)\n ]);\n throw new \\Exception('Invalid stream response from provider');\n }\n\n // For streaming responses, we need to exit after sending the stream\n // to prevent CodeIgniter from trying to send additional response data\n exit;\n } else {\n $response = $llm->process_request($model, $messages, $options);\n\n // Log usage\n $this->_log_usage($tenant_id, $external_id, $button_id, $provider, $model, $response['tokens_in'] + $response['tokens_out'], null, $has_image);\n\n // Return successful response\n return $this->response->setJSON([\n 'success' => true,\n 'response' => $response['response'],\n 'tokens_in' => $response['tokens_in'],\n 'tokens_out' => $response['tokens_out']\n ]);\n }\n } catch (\\Exception $e) {\n log_error('PROXY', 'Error processing LLM request', [\n 'error' => $e->getMessage(),\n 'provider' => $provider,\n 'model' => $model\n ]);\n\n return $this->response\n ->setStatusCode(500)\n ->setJSON([\n 'success' => false,\n 'error' => $e->getMessage()\n ]);\n }\n }\n\n /**\n * Log usage for billing purposes\n */\n private function _log_usage($tenant_id, $external_id, $button_id, $provider, $model, $total_tokens, $cost = null, $has_image = false)\n {\n try {\n $db = db_connect();\n\n // Generate usage_id\n helper('hash');\n $usage_id = generate_hash_id('usage');\n\n // Calculate cost if not provided\n if ($cost === null) {\n $cost = $this->_calculate_cost($provider, $model, $total_tokens);\n }\n\n $data = [\n 'usage_id' => $usage_id,\n 'tenant_id' => $tenant_id,\n 'user_id' => $external_id,\n 'external_id' => $external_id,\n 'button_id' => $button_id,\n 'provider' => $provider,\n 'model' => $model,\n 'tokens' => $total_tokens,\n 'cost' => $cost ?? 0,\n 'has_image' => $has_image ? 1 : 0,\n 'status' => 'success',\n 'created_at' => date('Y-m-d H:i:s'),\n 'updated_at' => date('Y-m-d H:i:s')\n ];\n\n log_debug('USAGE', 'Intentando insertar log', [\n 'data' => $data\n ]);\n\n $result = $db->table('usage_logs')->insert($data);\n\n if (!$result) {\n $error = $db->error();\n log_error('USAGE', 'Error al insertar log', [\n 'error' => $error,\n 'last_query' => $db->getLastQuery() ? $db->getLastQuery()->getQuery() : 'No query available'\n ]);\n return false;\n }\n\n $usage_log_id = $db->insertID();\n\n // Extraer system prompt y messages del request\n $request = $this->request->getJSON();\n if ($request && isset($request->messages)) {\n $system_prompt = null;\n foreach ($request->messages as $msg) {\n if ($msg->role === 'system') {\n $system_prompt = $msg->content;\n break;\n }\n }\n\n // TODO: Crear tabla prompt_logs para logging detallado de prompts\n // Temporalmente comentado hasta crear la tabla\n /*\n $prompt_data = [\n 'usage_log_id' => $usage_log_id,\n 'tenant_id' => $tenant_id,\n 'button_id' => $button_id,\n 'messages' => json_encode($request->messages),\n 'system_prompt' => $system_prompt,\n 'created_at' => date('Y-m-d H:i:s')\n ];\n\n $result = $db->table('prompt_logs')->insert($prompt_data);\n if (!$result) {\n log_error('USAGE', 'Error al insertar prompt log', [\n 'error' => $db->error(),\n 'data' => $prompt_data\n ]);\n }\n */\n }\n\n log_debug('USAGE', 'Log insertado correctamente', [\n 'usage_id' => $usage_id,\n 'insert_id' => $usage_log_id\n ]);\n\n return true;\n } catch (\\Exception $e) {\n log_error('USAGE', 'Excepción al insertar log', [\n 'error' => $e->getMessage(),\n 'trace' => $e->getTraceAsString()\n ]);\n return false;\n }\n }\n\n /**\n * Calculate cost based on provider and model\n */\n private function _calculate_cost($provider, $model, $tokens)\n {\n $rates = [\n 'openai' => [\n 'gpt-4-turbo' => 0.00003,\n 'gpt-4-vision' => 0.00003,\n 'gpt-3.5-turbo' => 0.000002,\n 'default' => 0.00001\n ],\n 'anthropic' => [\n 'claude-3-opus-20240229' => 0.00015,\n 'claude-3-sonnet-20240229' => 0.00003,\n 'claude-3-haiku-20240307' => 0.00001,\n 'default' => 0.00003\n ],\n 'mistral' => [\n 'mistral-large' => 0.00002,\n 'default' => 0.00001\n ],\n 'default' => 0.00001\n ];\n\n $provider_rates = $rates[$provider] ?? $rates['default'];\n $rate = $provider_rates[$model] ?? $provider_rates['default'];\n\n return round($tokens * $rate, 4);\n }\n\n /**\n * Extract domain from request headers\n */\n private function _extract_domain_from_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n $referer = service('request')->getHeaderLine('Referer');\n\n if (!empty($origin)) {\n $parsed = parse_url($origin);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } elseif (!empty($referer)) {\n $parsed = parse_url($referer);\n $domain = $parsed['host'];\n if (isset($parsed['port'])) {\n $domain .= ':' . $parsed['port'];\n }\n } else {\n $domain = service('request')->getIPAddress();\n }\n\n // Limpiar el dominio de la misma manera que en Domains.php\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Normaliza un dominio para comparación\n */\n private function _normalize_domain($domain)\n {\n // Si el dominio es '*', retornarlo tal cual\n if ($domain === '*') {\n return $domain;\n }\n\n // Remover protocolo y www si existen\n $domain = preg_replace('#^https?://#', '', $domain);\n $domain = preg_replace('#^www\\.#', '', $domain);\n\n return $domain;\n }\n\n /**\n * Get LLM provider instance\n */\n private function _get_llm_provider($provider, $tenant_id = null)\n {\n $api_key = null;\n \n // First try to get API key from database if tenant_id is provided\n if ($tenant_id) {\n $apiKeysModel = new \\App\\Models\\ApiKeysModel();\n $apiKeyRecord = $apiKeysModel->getDefaultKey($tenant_id, $provider);\n \n log_debug('PROXY', 'Database API key lookup result', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'found_record' => !empty($apiKeyRecord),\n 'has_api_key' => !empty($apiKeyRecord['api_key']) ? 'yes' : 'no',\n 'api_key_length' => !empty($apiKeyRecord['api_key']) ? strlen($apiKeyRecord['api_key']) : 0,\n 'record_data' => $apiKeyRecord ? array_merge($apiKeyRecord, ['api_key' => '***REDACTED***']) : null\n ]);\n \n if ($apiKeyRecord && !empty($apiKeyRecord['api_key'])) {\n $api_key = $apiKeyRecord['api_key']; // Already decrypted by the model's afterFind method\n log_info('PROXY', 'Using database API key for provider', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'api_key_name' => $apiKeyRecord['name'] ?? 'Unknown',\n 'api_key_id' => $apiKeyRecord['api_key_id'] ?? 'Unknown'\n ]);\n } else {\n log_warning('PROXY', 'No database API key found for provider, falling back to environment', [\n 'provider' => $provider,\n 'tenant_id' => $tenant_id,\n 'available_keys_query' => $apiKeysModel->where('tenant_id', $tenant_id)->where('active', 1)->findAll()\n ]);\n }\n }\n \n // Fallback to environment variables if no database key found\n if (!$api_key) {\n if (!isset($this->api_keys[$provider]) || empty($this->api_keys[$provider])) {\n throw new \\Exception('Provider not configured: ' . $provider . ' (no API key found in database or environment)');\n }\n $api_key = $this->api_keys[$provider];\n log_debug('PROXY', 'Using environment API key for provider', [\n 'provider' => $provider\n ]);\n }\n\n switch ($provider) {\n case 'openai':\n return new OpenAiProvider($api_key, $this->endpoints[$provider]);\n case 'anthropic':\n return new AnthropicProvider($api_key, $this->endpoints[$provider]);\n case 'mistral':\n return new MistralProvider($api_key, $this->endpoints[$provider]);\n case 'deepseek':\n return new DeepseekProvider($api_key, $this->endpoints[$provider]);\n case 'google':\n return new GoogleProvider($api_key, $this->endpoints[$provider]);\n case 'azure':\n return new AzureProvider($api_key, $this->endpoints[$provider]);\n default:\n throw new \\Exception('Unsupported provider: ' . $provider);\n }\n }\n\n /**\n * Initialize proxy configuration\n */\n private function _initialize_config()\n {\n $this->api_keys = [\n 'openai' => env('OPENAI_API_KEY', ''),\n 'anthropic' => env('ANTHROPIC_API_KEY', ''),\n 'mistral' => env('MISTRAL_API_KEY', ''),\n 'deepseek' => env('DEEPSEEK_API_KEY', ''),\n 'google' => env('GOOGLE_API_KEY', ''),\n 'azure' => env('AZURE_API_KEY', '')\n ];\n\n $this->endpoints = [\n 'openai' => env('OPENAI_API_ENDPOINT', 'https://api.openai.com/v1/chat/completions'),\n 'anthropic' => env('ANTHROPIC_API_ENDPOINT', 'https://api.anthropic.com/v1/messages'),\n 'mistral' => env('MISTRAL_API_ENDPOINT', 'https://api.mistral.ai/v1/chat/completions'),\n 'deepseek' => env('DEEPSEEK_API_ENDPOINT', 'https://api.deepseek.com/v1/chat/completions'),\n 'google' => env('GOOGLE_API_ENDPOINT', 'https://generativelanguage.googleapis.com/v1/models'),\n 'azure' => env('AZURE_API_ENDPOINT', '')\n ];\n\n $this->use_simulated_responses = env('USE_SIMULATED_RESPONSES', false);\n $this->allowed_origins = env('ALLOWED_ORIGINS', '*');\n }\n\n /**\n * Process LLM request\n */\n public function process()\n {\n return $this->index();\n }\n\n /**\n * Handle OPTIONS request for CORS\n */\n public function options()\n {\n $this->_set_cors_headers();\n return $this->response\n ->setStatusCode(200);\n }\n\n /**\n * Set CORS headers for all responses\n */\n private function _set_cors_headers()\n {\n $origin = service('request')->getHeaderLine('Origin');\n \n if (!empty($origin)) {\n // Get allowed domains from database same as CorsFilter\n $db = db_connect();\n $buttonsQuery = $db->query(\"SELECT DISTINCT domain FROM buttons WHERE status = 'active'\");\n $allowedDomains = [];\n \n if ($buttonsQuery) {\n $buttonRows = $buttonsQuery->getResultArray();\n foreach ($buttonRows as $row) {\n if (!empty($row['domain'])) {\n // Handle comma-separated domains\n $domains = explode(',', $row['domain']);\n foreach ($domains as $domain) {\n $domain = trim($domain);\n if (!empty($domain)) {\n $allowedDomains[] = $domain;\n }\n }\n }\n }\n }\n \n // Check if origin is allowed\n if (in_array($origin, $allowedDomains) || $this->allowed_origins === '*') {\n header(\"Access-Control-Allow-Origin: {$origin}\");\n header('Access-Control-Allow-Credentials: true');\n log_debug('PROXY', 'CORS headers set for streaming', [\n 'origin' => $origin,\n 'allowed' => true\n ]);\n }\n } else {\n // Fallback to wildcard if no origin (development)\n if ($this->allowed_origins === '*') {\n header('Access-Control-Allow-Origin: *');\n }\n }\n \n header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept, Origin');\n header('Access-Control-Max-Age: 86400');\n }\n}\n"}}}} |