|null */ private static ?array $configCache = null; /** @var array Default options */ private static array $defaultOptions = [ 'poll_interval' => 5, 'poll_timeout' => 300, 'timeout' => 30, 'verify_tls' => true, 'max_retries' => 3, 'retry_delay' => 2, ]; /** * Create an AI response (async: auto-polls status until ready). * Signature compatible with the OpenAI Responses API. * * Usage: * require_once __DIR__ . '/ai/LocalAIApi.php'; * $response = LocalAIApi::createResponse([ * 'input' => [ * ['role' => 'system', 'content' => 'You are a helpful assistant.'], * ['role' => 'user', 'content' => 'Tell me a bedtime story.'], * ], * ]); * if (!empty($response['success'])) { * // response['data'] contains full payload * $decoded = LocalAIApi::decodeJsonFromResponse($response); * } * * @param array $params Request body (model, input, text, reasoning, metadata, etc.) * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid) * @return array{ * success: bool, * status?: int, * data?: mixed, * error?: string, * response?: mixed, * message?: string * } */ public static function createResponse(array $params, array $options = []): array { $cfg = self::config(); $payload = $params; // Validate input if (empty($payload['input']) || !is_array($payload['input'])) { return [ 'success' => false, 'error' => 'input_missing', 'message' => 'Parameter "input" is required and must be an array.', ]; } // Set default model if not provided if (!isset($payload['model']) || $payload['model'] === '') { $payload['model'] = $cfg['default_model']; } // Merge with default options $options = array_merge(self::$defaultOptions, $options); // Make initial request $initial = self::request($options['path'] ?? null, $payload, $options); if (empty($initial['success'])) { return $initial; } // Async flow: if backend returns ai_request_id, poll status until ready $data = $initial['data'] ?? null; if (is_array($data) && isset($data['ai_request_id'])) { $aiRequestId = $data['ai_request_id']; return self::awaitResponse($aiRequestId, $options); } return $initial; } /** * Snake_case alias for createResponse (matches the provided example). * * @param array $params * @param array $options * @return array */ public static function create_response(array $params, array $options = []): array { return self::createResponse($params, $options); } /** * Perform a raw request to the AI proxy. * * @param string|null $path Endpoint (may be an absolute URL) * @param array $payload JSON payload * @param array $options Additional request options * @return array */ public static function request(?string $path = null, array $payload = [], array $options = []): array { $cfg = self::config(); $projectUuid = $cfg['project_uuid']; if (empty($projectUuid)) { return [ 'success' => false, 'error' => 'project_uuid_missing', 'message' => 'PROJECT_UUID is not defined; aborting AI request.', ]; } $defaultPath = $cfg['responses_path'] ?? null; $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); if (empty($resolvedPath)) { return [ 'success' => false, 'error' => 'project_id_missing', 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', ]; } $url = self::buildUrl($resolvedPath, $cfg['base_url']); $timeout = $options['timeout'] ?? $cfg['timeout'] ?? 30; $verifyTls = $options['verify_tls'] ?? $cfg['verify_tls'] ?? true; $maxRetries = $options['max_retries'] ?? $cfg['max_retries'] ?? 3; $retryDelay = $options['retry_delay'] ?? $cfg['retry_delay'] ?? 2; $projectHeader = $cfg['project_header']; $headers = [ 'Content-Type: application/json', 'Accept: application/json', ]; $headers[] = $projectHeader . ': ' . $projectUuid; if (!empty($options['headers']) && is_array($options['headers'])) { foreach ($options['headers'] as $header) { if (is_string($header) && $header !== '') { $headers[] = $header; } } } // Add project_uuid to payload if not present if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { $payload['project_uuid'] = $projectUuid; } // Add debug info if enabled if ($cfg['debug'] ?? false) { $payload['debug'] = true; } $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($body === false) { return [ 'success' => false, 'error' => 'json_encode_failed', 'message' => 'Failed to encode request body to JSON.', ]; } // Retry logic $attempt = 0; $lastError = null; while ($attempt < $maxRetries) { $attempt++; $result = self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls); if ($result['success']) { return $result; } $lastError = $result; // Don't retry on client errors (4xx) $status = $result['status'] ?? 0; if ($status >= 400 && $status < 500) { break; } // Wait before retrying if ($attempt < $maxRetries) { sleep($retryDelay); } } return $lastError ?? [ 'success' => false, 'error' => 'max_retries_exceeded', 'message' => 'Maximum retry attempts exceeded.', ]; } /** * Poll AI request status until ready or timeout. * * @param int|string $aiRequestId * @param array $options * @return array */ public static function awaitResponse($aiRequestId, array $options = []): array { $cfg = self::config(); $timeout = $options['poll_timeout'] ?? 300; // seconds $interval = $options['poll_interval'] ?? 5; // seconds if ($interval <= 0) { $interval = 5; } $perCallTimeout = $options['timeout'] ?? null; $deadline = time() + max($timeout, $interval); $headers = $options['headers'] ?? []; $attempt = 0; while (true) { $attempt++; $statusResp = self::fetchStatus($aiRequestId, [ 'headers' => $headers, 'timeout' => $perCallTimeout, ]); if (!empty($statusResp['success'])) { $data = $statusResp['data'] ?? []; if (is_array($data)) { $statusValue = $data['status'] ?? null; if ($statusValue === 'success' || $statusValue === 'completed') { return [ 'success' => true, 'status' => 200, 'data' => $data['response'] ?? $data, 'attempts' => $attempt, ]; } if ($statusValue === 'failed' || $statusValue === 'error') { return [ 'success' => false, 'status' => 500, 'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed', 'data' => $data, 'attempts' => $attempt, ]; } } } else { return $statusResp; } if (time() >= $deadline) { return [ 'success' => false, 'error' => 'timeout', 'message' => 'Timed out waiting for AI response after ' . $attempt . ' attempts.', 'attempts' => $attempt, ]; } // Exponential backoff $sleepTime = $interval * (1 + ($attempt * 0.1)); sleep(min($sleepTime, 30)); // Max 30 seconds between polls } } /** * Fetch status for queued AI request. * * @param int|string $aiRequestId * @param array $options * @return array */ public static function fetchStatus($aiRequestId, array $options = []): array { $cfg = self::config(); $projectUuid = $cfg['project_uuid']; if (empty($projectUuid)) { return [ 'success' => false, 'error' => 'project_uuid_missing', 'message' => 'PROJECT_UUID is not defined; aborting status check.', ]; } $statusPath = self::resolveStatusPath($aiRequestId, $cfg); $url = self::buildUrl($statusPath, $cfg['base_url']); $timeout = $options['timeout'] ?? $cfg['timeout'] ?? 30; $verifyTls = $options['verify_tls'] ?? $cfg['verify_tls'] ?? true; $projectHeader = $cfg['project_header']; $headers = [ 'Accept: application/json', $projectHeader . ': ' . $projectUuid, ]; if (!empty($options['headers']) && is_array($options['headers'])) { foreach ($options['headers'] as $header) { if (is_string($header) && $header !== '') { $headers[] = $header; } } } return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls); } /** * Extract plain text from a Responses API payload. * * @param array $response Result of LocalAIApi::createResponse|request * @return string */ public static function extractText(array $response): string { $payload = $response['data'] ?? $response; if (!is_array($payload)) { return ''; } // Try to extract from OpenAI Responses API format if (!empty($payload['output']) && is_array($payload['output'])) { $combined = ''; foreach ($payload['output'] as $item) { if (!isset($item['content']) || !is_array($item['content'])) { continue; } foreach ($item['content'] as $block) { if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { $combined .= $block['text']; } } } if ($combined !== '') { return $combined; } } // Try to extract from OpenAI Chat Completion format if (!empty($payload['choices'][0]['message']['content'])) { return (string) $payload['choices'][0]['message']['content']; } // Try to extract from generic response if (!empty($payload['text'])) { return (string) $payload['text']; } return ''; } /** * Attempt to decode JSON emitted by the model (handles markdown fences). * * @param array $response * @return array|null */ public static function decodeJsonFromResponse(array $response): ?array { $text = self::extractText($response); if ($text === '') { return null; } // First try to decode directly $decoded = json_decode($text, true); if (is_array($decoded)) { return $decoded; } // Try stripping markdown code fences $stripped = preg_replace('/^```(?:json)?\s*\n|\n```$/m', '', trim($text)); if ($stripped !== null && $stripped !== $text) { $decoded = json_decode($stripped, true); if (is_array($decoded)) { return $decoded; } } // Try to extract JSON from text if (preg_match('/\{.*\}/s', $text, $matches)) { $decoded = json_decode($matches[0], true); if (is_array($decoded)) { return $decoded; } } return null; } /** * Load configuration from ai/config.php. * * @return array * @throws RuntimeException */ private static function config(): array { if (self::$configCache === null) { $configPath = __DIR__ . '/config.php'; if (!file_exists($configPath)) { throw new RuntimeException('AI config file not found: ai/config.php'); } $cfg = require $configPath; if (!is_array($cfg)) { throw new RuntimeException('Invalid AI config format: expected array'); } // Merge with defaults $defaults = [ 'debug' => false, 'max_retries' => 3, 'retry_delay' => 2, 'features' => [ 'async_polling' => true, 'json_response' => true, 'streaming' => false, ], ]; self::$configCache = array_merge($defaults, $cfg); } return self::$configCache; } /** * Build an absolute URL from base_url and a path. */ private static function buildUrl(string $path, string $baseUrl): string { $trimmed = trim($path); if ($trimmed === '') { return $baseUrl; } if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { return $trimmed; } if ($trimmed[0] === '/') { return $baseUrl . $trimmed; } return $baseUrl . '/' . $trimmed; } /** * Resolve status path based on configured responses_path and ai_request_id. * * @param int|string $aiRequestId * @param array $cfg * @return string */ private static function resolveStatusPath($aiRequestId, array $cfg): string { $basePath = $cfg['responses_path'] ?? ''; $trimmed = rtrim($basePath, '/'); if ($trimmed === '') { return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status'; } if (substr($trimmed, -11) !== '/ai-request') { $trimmed .= '/ai-request'; } return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status'; } /** * Shared CURL sender for GET/POST requests. * * @param string $url * @param string $method * @param string|null $body * @param array $headers * @param int $timeout * @param bool $verifyTls * @return array */ private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): array { if (!function_exists('curl_init')) { return [ 'success' => false, 'error' => 'curl_missing', 'message' => 'PHP cURL extension is missing. Install or enable it on the server.', ]; } $ch = curl_init($url); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); curl_setopt($ch, CURLOPT_FAILONERROR, false); // Add debug info if enabled $cfg = self::config(); if ($cfg['debug'] ?? false) { curl_setopt($ch, CURLOPT_VERBOSE, true); $debugFile = fopen(__DIR__ . '/../logs/curl_debug.log', 'a'); if ($debugFile) { curl_setopt($ch, CURLOPT_STDERR, $debugFile); } } $upper = strtoupper($method); if ($upper === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); } else { curl_setopt($ch, CURLOPT_HTTPGET, true); } $responseBody = curl_exec($ch); $error = curl_error($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Close debug file if opened if (isset($debugFile) && is_resource($debugFile)) { fclose($debugFile); } if ($responseBody === false) { return [ 'success' => false, 'error' => 'curl_error', 'message' => $error ?: 'Unknown cURL error', 'status' => $status, ]; } $decoded = null; if ($responseBody !== '' && $responseBody !== null) { $decoded = json_decode($responseBody, true); if (json_last_error() !== JSON_ERROR_NONE) { $decoded = null; } } if ($status >= 200 && $status < 300) { return [ 'success' => true, 'status' => $status, 'data' => $decoded ?? $responseBody, ]; } $errorMessage = 'AI proxy request failed'; if (is_array($decoded)) { $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; } elseif (is_string($responseBody) && $responseBody !== '') { $errorMessage = $responseBody; } return [ 'success' => false, 'status' => $status, 'error' => $errorMessage, 'response' => $decoded ?? $responseBody, ]; } } // Legacy alias for backward compatibility with the previous class name. if (!class_exists('OpenAIService')) { class_alias(LocalAIApi::class, 'OpenAIService'); } // Helper function for quick usage if (!function_exists('ai_request')) { /** * Helper function for quick AI requests * * @param array $params * @param array $options * @return array */ function ai_request(array $params, array $options = []): array { return LocalAIApi::createResponse($params, $options); } }