422 lines
14 KiB
PHP
422 lines
14 KiB
PHP
<?php
|
|
// LocalAIApi — proxy client for the 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'])) {
|
|
// $decoded = LocalAIApi::decodeJsonFromResponse($response);
|
|
// }
|
|
|
|
class LocalAIApi
|
|
{
|
|
/** @var array<string,mixed>|null */
|
|
private static ?array $configCache = null;
|
|
|
|
/**
|
|
* Signature compatible with the OpenAI Responses API.
|
|
*
|
|
* @param array<string,mixed> $params Request body (model, input, text, reasoning, metadata, etc.).
|
|
* @param array<string,mixed> $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;
|
|
|
|
if (empty($payload['input']) || !is_array($payload['input'])) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'input_missing',
|
|
'message' => 'Parameter "input" is required and must be an array.',
|
|
];
|
|
}
|
|
|
|
if (!isset($payload['model']) || $payload['model'] === '') {
|
|
$payload['model'] = $cfg['default_model'];
|
|
}
|
|
|
|
$resp = self::request($options['path'] ?? null, $payload, $options);
|
|
|
|
if ($resp['success'] && isset($resp['data']['ai_request_id']) && isset($resp['data']['status']) && $resp['data']['status'] === 'queued') {
|
|
return self::awaitResponse($resp['data']['ai_request_id'], $options);
|
|
}
|
|
|
|
return $resp;
|
|
}
|
|
|
|
/**
|
|
* Poll the status of an asynchronous AI request.
|
|
*
|
|
* @param string|int $requestId
|
|
* @param array<string,mixed> $options
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function awaitResponse($requestId, array $options = []): array
|
|
{
|
|
$interval = (int) ($options['poll_interval'] ?? 5);
|
|
$timeout = (int) ($options['poll_timeout'] ?? 300);
|
|
$start = time();
|
|
|
|
while (time() - $start < $timeout) {
|
|
$statusResp = self::fetchStatus($requestId, $options);
|
|
if (!$statusResp['success']) {
|
|
return $statusResp;
|
|
}
|
|
|
|
$data = $statusResp['data'] ?? [];
|
|
$status = $data['status'] ?? 'unknown';
|
|
|
|
if ($status === 'completed' || $status === 'success') {
|
|
return $statusResp;
|
|
}
|
|
|
|
if ($status === 'failed' || $status === 'cancelled') {
|
|
return [
|
|
'success' => false,
|
|
'error' => $status,
|
|
'message' => $data['error'] ?? "AI request $status",
|
|
'data' => $data
|
|
];
|
|
}
|
|
|
|
sleep($interval);
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => 'timeout',
|
|
'message' => 'Timed out waiting for AI response.'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fetch the status of an AI request.
|
|
*
|
|
* @param string|int $requestId
|
|
* @param array<string,mixed> $options
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function fetchStatus($requestId, array $options = []): array
|
|
{
|
|
$cfg = self::config();
|
|
$projectId = $cfg['project_id'];
|
|
$path = "/projects/{$projectId}/ai-request/{$requestId}/status";
|
|
|
|
$ch = curl_init(self::buildUrl($path, $cfg['base_url']));
|
|
|
|
$headers = [
|
|
'Accept: application/json',
|
|
$cfg['project_header'] . ': ' . $cfg['project_uuid']
|
|
];
|
|
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $cfg['verify_tls'] ?? true);
|
|
|
|
$responseBody = curl_exec($ch);
|
|
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if (file_exists(__DIR__ . '/../api/scan_debug.log')) {
|
|
file_put_contents(__DIR__ . '/../api/scan_debug.log', date('Y-m-d H:i:s') . " fetchStatus($requestId) status: $status, body: $responseBody" . PHP_EOL, FILE_APPEND);
|
|
}
|
|
|
|
$decoded = json_decode($responseBody, true);
|
|
if ($status >= 200 && $status < 300) {
|
|
return [
|
|
'success' => true,
|
|
'status' => $status,
|
|
'data' => $decoded
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'status' => $status,
|
|
'error' => $decoded['error'] ?? 'Failed to fetch status'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Snake_case alias for createResponse (matches the provided example).
|
|
*
|
|
* @param array<string,mixed> $params
|
|
* @param array<string,mixed> $options
|
|
* @return array<string,mixed>
|
|
*/
|
|
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 $path Endpoint (may be an absolute URL).
|
|
* @param array<string,mixed> $payload JSON payload.
|
|
* @param array<string,mixed> $options Additional request options.
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function request(?string $path = null, array $payload = [], array $options = []): array
|
|
{
|
|
if (!function_exists('curl_init')) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'curl_missing',
|
|
'message' => 'PHP cURL extension is missing. Install or enable it on the VM.',
|
|
];
|
|
}
|
|
|
|
$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']);
|
|
$baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30;
|
|
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout;
|
|
if ($timeout <= 0) {
|
|
$timeout = 30;
|
|
}
|
|
|
|
$baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true;
|
|
$verifyTls = array_key_exists('verify_tls', $options)
|
|
? (bool) $options['verify_tls']
|
|
: $baseVerifyTls;
|
|
|
|
$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;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) {
|
|
$payload['project_uuid'] = $projectUuid;
|
|
}
|
|
|
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
if (file_exists(__DIR__ . '/../api/scan_debug.log')) {
|
|
// Log a truncated version of the body if it's too large (images are large)
|
|
$logBody = strlen($body) > 1000 ? substr($body, 0, 1000) . '... [TRUNCATED]' : $body;
|
|
file_put_contents(__DIR__ . '/../api/scan_debug.log', date('Y-m-d H:i:s') . " AI Request to $url: " . $logBody . PHP_EOL, FILE_APPEND);
|
|
}
|
|
if ($body === false) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'json_encode_failed',
|
|
'message' => 'Failed to encode request body to JSON.',
|
|
];
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
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);
|
|
|
|
$responseBody = curl_exec($ch);
|
|
if ($responseBody === false) {
|
|
$error = curl_error($ch) ?: 'Unknown cURL error';
|
|
curl_close($ch);
|
|
return [
|
|
'success' => false,
|
|
'error' => 'curl_error',
|
|
'message' => $error,
|
|
];
|
|
}
|
|
|
|
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
$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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Extract plain text from a Responses API payload.
|
|
*
|
|
* @param array<string,mixed> $response Result of LocalAIApi::createResponse|request.
|
|
* @return string
|
|
*/
|
|
public static function extractText(array $response): string
|
|
{
|
|
$payload = $response['data'] ?? $response;
|
|
if (!is_array($payload)) {
|
|
return '';
|
|
}
|
|
|
|
// If the payload contains a 'response' object (typical for status checks), use it.
|
|
if (isset($payload['response']) && is_array($payload['response'])) {
|
|
$payload = $payload['response'];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!empty($payload['choices'][0]['message']['content'])) {
|
|
return (string) $payload['choices'][0]['message']['content'];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Attempt to decode JSON emitted by the model (handles markdown fences).
|
|
*
|
|
* @param array<string,mixed> $response
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public static function decodeJsonFromResponse(array $response): ?array
|
|
{
|
|
$text = self::extractText($response);
|
|
if ($text === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($text, true);
|
|
if (is_array($decoded)) {
|
|
return $decoded;
|
|
}
|
|
|
|
$stripped = preg_replace('/^```json|```$/m', '', trim($text));
|
|
if ($stripped !== null && $stripped !== $text) {
|
|
$decoded = json_decode($stripped, true);
|
|
if (is_array($decoded)) {
|
|
return $decoded;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Load configuration from ai/config.php.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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');
|
|
}
|
|
self::$configCache = $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;
|
|
}
|
|
}
|
|
|
|
// Legacy alias for backward compatibility with the previous class name.
|
|
if (!class_exists('OpenAIService')) {
|
|
class_alias(LocalAIApi::class, 'OpenAIService');
|
|
}
|