mirror of
https://github.com/ellite/Wallos.git
synced 2026-07-30 09:45:52 -04:00
406 lines
12 KiB
PHP
406 lines
12 KiB
PHP
<?php
|
|
/*
|
|
Shared AI provider client for the endpoints in endpoints/ai/.
|
|
Callers must have loaded connect_endpoint.php (for $db) and ssrf_helper.php.
|
|
*/
|
|
|
|
function ai_load_settings($db, $userId)
|
|
{
|
|
$stmt = $db->prepare("SELECT * FROM ai_settings WHERE user_id = ?");
|
|
$stmt->bindValue(1, $userId, SQLITE3_INTEGER);
|
|
$result = $stmt->execute();
|
|
$aiSettings = $result->fetchArray(SQLITE3_ASSOC);
|
|
$stmt->close();
|
|
|
|
return $aiSettings ?: null;
|
|
}
|
|
|
|
function ai_log_failure($type, $userId, $reason, $context = [])
|
|
{
|
|
$details = [
|
|
'provider=' . ($type ?: 'unknown'),
|
|
'user_id=' . (int) $userId,
|
|
'reason=' . $reason,
|
|
];
|
|
|
|
foreach ($context as $key => $value) {
|
|
if ($value !== null && $value !== '') {
|
|
$details[] = $key . '=' . preg_replace('/[\r\n\s]+/', ' ', (string) $value);
|
|
}
|
|
}
|
|
|
|
error_log('[Wallos AI] ' . implode(' ', $details));
|
|
}
|
|
|
|
function ai_get_provider_error($replyData)
|
|
{
|
|
if (!is_array($replyData)) {
|
|
return [null, null, null];
|
|
}
|
|
|
|
$error = $replyData['error'] ?? null;
|
|
|
|
if (is_string($error) && trim($error) !== '') {
|
|
return [trim($error), null, null];
|
|
}
|
|
|
|
if (is_array($error)) {
|
|
return [
|
|
isset($error['message']) && is_string($error['message']) ? trim($error['message']) : null,
|
|
$error['code'] ?? null,
|
|
$error['correlation_id'] ?? null,
|
|
];
|
|
}
|
|
|
|
return [null, null, null];
|
|
}
|
|
|
|
function ai_get_text_content($content)
|
|
{
|
|
if (is_string($content)) {
|
|
return $content;
|
|
}
|
|
|
|
if (!is_array($content)) {
|
|
return null;
|
|
}
|
|
|
|
$text = [];
|
|
foreach ($content as $part) {
|
|
if (is_array($part) && isset($part['text']) && is_string($part['text'])) {
|
|
$text[] = $part['text'];
|
|
}
|
|
}
|
|
|
|
return $text ? implode('', $text) : null;
|
|
}
|
|
|
|
function ai_decode_json_values($content)
|
|
{
|
|
if (!is_string($content) || $content === '') {
|
|
return [];
|
|
}
|
|
|
|
$values = [];
|
|
$length = strlen($content);
|
|
$start = null;
|
|
$depth = 0;
|
|
$inString = false;
|
|
$escaped = false;
|
|
|
|
for ($index = 0; $index < $length; $index++) {
|
|
$character = $content[$index];
|
|
|
|
if ($start === null) {
|
|
if ($character === '{' || $character === '[') {
|
|
$start = $index;
|
|
$depth = 1;
|
|
$inString = false;
|
|
$escaped = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($inString) {
|
|
if ($escaped) {
|
|
$escaped = false;
|
|
} elseif ($character === '\\') {
|
|
$escaped = true;
|
|
} elseif ($character === '"') {
|
|
$inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($character === '"') {
|
|
$inString = true;
|
|
} elseif ($character === '{' || $character === '[') {
|
|
$depth++;
|
|
} elseif ($character === '}' || $character === ']') {
|
|
$depth--;
|
|
|
|
if ($depth === 0) {
|
|
$json = substr($content, $start, $index - $start + 1);
|
|
$decoded = json_decode($json, true);
|
|
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
$values[] = $decoded;
|
|
}
|
|
|
|
$start = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
function ai_decode_provider_response($reply)
|
|
{
|
|
$decoded = json_decode($reply, true);
|
|
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
return isset($decoded['summary']) && is_array($decoded['summary'])
|
|
? $decoded['summary']
|
|
: $decoded;
|
|
}
|
|
|
|
$documents = ai_decode_json_values($reply);
|
|
$completion = null;
|
|
$fallback = null;
|
|
$streamedContent = '';
|
|
|
|
foreach ($documents as $document) {
|
|
if (!is_array($document)) {
|
|
continue;
|
|
}
|
|
|
|
$candidate = isset($document['summary']) && is_array($document['summary'])
|
|
? $document['summary']
|
|
: $document;
|
|
$fallback = $candidate;
|
|
|
|
if (isset($candidate['choices'][0]['message']['content'])
|
|
|| isset($candidate['candidates'][0]['content']['parts'][0]['text'])
|
|
|| isset($candidate['response'])
|
|
|| isset($candidate['error'])) {
|
|
$completion = $candidate;
|
|
}
|
|
|
|
$delta = $candidate['choices'][0]['delta']['content'] ?? null;
|
|
if (is_string($delta)) {
|
|
$streamedContent .= $delta;
|
|
}
|
|
}
|
|
|
|
if ($completion !== null) {
|
|
return $completion;
|
|
}
|
|
|
|
if ($streamedContent !== '') {
|
|
return [
|
|
'choices' => [[
|
|
'message' => ['role' => 'assistant', 'content' => $streamedContent],
|
|
'finish_reason' => 'stop',
|
|
]],
|
|
];
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/*
|
|
Validates the settings and runs one completion request against the configured provider.
|
|
Returns ["success" => true, "content" => string]
|
|
or ["success" => false, "message" => string].
|
|
*/
|
|
function ai_complete($aiSettings, $prompt, $db, $i18n, $userId)
|
|
{
|
|
$type = $aiSettings['type'] ?? '';
|
|
$enabled = !empty($aiSettings['enabled']);
|
|
$model = $aiSettings['model'] ?? '';
|
|
$host = $aiSettings['url'] ?? '';
|
|
$apiKey = $aiSettings['api_key'] ?? '';
|
|
|
|
if (!in_array($type, ['chatgpt', 'gemini', 'openrouter', 'ollama', 'openai-compatible']) || !$enabled || empty($model)) {
|
|
return ["success" => false, "message" => translate('error', $i18n)];
|
|
}
|
|
|
|
$ssrf = null;
|
|
|
|
// Host-based providers
|
|
if (in_array($type, ['ollama', 'openai-compatible'])) {
|
|
if (empty($host)) {
|
|
return ["success" => false, "message" => translate('invalid_host', $i18n)];
|
|
}
|
|
|
|
$parsedUrl = parse_url($host);
|
|
if (
|
|
!isset($parsedUrl['scheme']) ||
|
|
!in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) ||
|
|
!filter_var($host, FILTER_VALIDATE_URL)
|
|
) {
|
|
return ["success" => false, "message" => translate('invalid_host', $i18n)];
|
|
}
|
|
|
|
$ssrf = validate_webhook_url_for_ssrf($host, $db, $i18n, $userId);
|
|
|
|
if ($type === 'ollama') {
|
|
$apiKey = '';
|
|
}
|
|
} else {
|
|
if (empty($apiKey)) {
|
|
return ["success" => false, "message" => translate('invalid_api_key', $i18n)];
|
|
}
|
|
}
|
|
|
|
$ch = curl_init();
|
|
|
|
if ($type === 'ollama') {
|
|
curl_setopt($ch, CURLOPT_URL, rtrim($host, '/') . '/api/generate');
|
|
curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'model' => $model,
|
|
'prompt' => $prompt,
|
|
'stream' => false,
|
|
]));
|
|
} elseif ($type === 'openai-compatible') {
|
|
$headers = ['Content-Type: application/json'];
|
|
if (!empty($apiKey)) {
|
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
|
}
|
|
|
|
curl_setopt($ch, CURLOPT_URL, rtrim($host, '/') . '/chat/completions');
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
'stream' => false,
|
|
]));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
|
|
if ($ssrf) {
|
|
curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]);
|
|
}
|
|
} else {
|
|
$headers = ['Content-Type: application/json'];
|
|
|
|
if ($type === 'chatgpt') {
|
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
|
curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
]));
|
|
} elseif ($type === 'gemini') {
|
|
curl_setopt(
|
|
$ch,
|
|
CURLOPT_URL,
|
|
'https://generativelanguage.googleapis.com/v1beta/models/' .
|
|
urlencode($model) . ':generateContent?key=' . urlencode($apiKey)
|
|
);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'contents' => [[
|
|
'parts' => [['text' => $prompt]],
|
|
]],
|
|
]));
|
|
} elseif ($type === 'openrouter') {
|
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
|
curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
]));
|
|
}
|
|
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
}
|
|
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
|
|
|
|
$reply = curl_exec($ch);
|
|
|
|
$curlErrorNumber = curl_errno($ch);
|
|
$curlError = $curlErrorNumber ? curl_error($ch) : '';
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
|
if ($curlErrorNumber) {
|
|
ai_log_failure($type, $userId, 'curl_error', [
|
|
'curl_code' => $curlErrorNumber,
|
|
'http_status' => $httpCode,
|
|
]);
|
|
curl_close($ch);
|
|
return ["success" => false, "message" => $curlError];
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
$replyData = ai_decode_provider_response($reply);
|
|
|
|
if (!is_array($replyData)) {
|
|
ai_log_failure($type, $userId, 'invalid_provider_json', [
|
|
'http_status' => $httpCode,
|
|
'response_length' => is_string($reply) ? strlen($reply) : 0,
|
|
'json_error' => json_last_error_msg(),
|
|
]);
|
|
return ["success" => false, "message" => translate('ai_invalid_response', $i18n)];
|
|
}
|
|
|
|
[$providerMessage, $providerCode, $correlationId] = ai_get_provider_error($replyData);
|
|
|
|
if (($httpCode < 200 || $httpCode >= 300) && $providerMessage === null) {
|
|
$fallbackMessage = $replyData['message'] ?? null;
|
|
$providerMessage = is_string($fallbackMessage) ? trim($fallbackMessage) : null;
|
|
}
|
|
|
|
if ($httpCode < 200 || $httpCode >= 300 || $providerMessage !== null) {
|
|
ai_log_failure($type, $userId, 'provider_error', [
|
|
'http_status' => $httpCode,
|
|
'provider_code' => $providerCode,
|
|
'correlation_id' => $correlationId,
|
|
]);
|
|
|
|
$message = translate('ai_provider_error', $i18n);
|
|
if ($httpCode > 0) {
|
|
$message .= ' (HTTP ' . $httpCode . ')';
|
|
}
|
|
if ($providerMessage !== null && $providerMessage !== '') {
|
|
$providerMessage = function_exists('mb_substr')
|
|
? mb_substr($providerMessage, 0, 500)
|
|
: substr($providerMessage, 0, 500);
|
|
$message .= ': ' . $providerMessage;
|
|
}
|
|
|
|
return ["success" => false, "message" => $message];
|
|
}
|
|
|
|
$content = null;
|
|
|
|
if (in_array($type, ['chatgpt', 'openrouter', 'openai-compatible'])
|
|
&& isset($replyData['choices'][0]['message']['content'])) {
|
|
$content = ai_get_text_content($replyData['choices'][0]['message']['content']);
|
|
} elseif ($type === 'gemini'
|
|
&& isset($replyData['candidates'][0]['content']['parts'][0]['text'])) {
|
|
$content = $replyData['candidates'][0]['content']['parts'][0]['text'];
|
|
} elseif ($type === 'ollama') {
|
|
$content = $replyData['response'] ?? null;
|
|
}
|
|
|
|
if ($content === null) {
|
|
ai_log_failure($type, $userId, 'missing_completion_content', [
|
|
'http_status' => $httpCode,
|
|
'finish_reason' => $replyData['choices'][0]['finish_reason'] ?? null,
|
|
]);
|
|
return ["success" => false, "message" => translate('ai_invalid_response', $i18n)];
|
|
}
|
|
|
|
return ["success" => true, "content" => $content];
|
|
}
|
|
|
|
/*
|
|
Strips markdown code fences and decodes the model's JSON reply.
|
|
Returns the decoded value, or null when it isn't valid JSON.
|
|
*/
|
|
function ai_extract_json($content)
|
|
{
|
|
$content = trim($content);
|
|
$content = preg_replace('/^```(?:json)?\s*/i', '', $content);
|
|
$content = preg_replace('/\s*```$/', '', $content);
|
|
|
|
$decoded = json_decode(trim($content), true);
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
return $decoded;
|
|
}
|
|
|
|
// Some compatible models add explanations or repeat the JSON array.
|
|
// Return the first complete JSON value instead of treating adjacent arrays as one.
|
|
foreach (ai_decode_json_values($content) as $value) {
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|