248 lines
8.5 KiB
PHP
248 lines
8.5 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
|
|
* Copyright (C) 2026 Nick Fragoulis
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY, without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
* or see https://www.gnu.org/
|
|
*/
|
|
|
|
/**
|
|
* \file htdocs/ai/class/llmadapter.class.php
|
|
* \ingroup ai
|
|
* \brief Universal adapter for multiple LLM providers
|
|
*/
|
|
|
|
class UniversalLLMAdapter
|
|
{
|
|
/** @var string Stores the raw request for debugging */
|
|
public $lastRequest = "";
|
|
|
|
/** @var string Stores the raw response for debugging */
|
|
public $lastResponse = "";
|
|
|
|
/** @var string The type of LLM (e.g., 'openai', 'ollama') */
|
|
private $type;
|
|
|
|
/** @var string The API Key */
|
|
private $key;
|
|
|
|
/** @var string The Base URL for the API */
|
|
private $baseUrl;
|
|
|
|
/** @var string The model name to use */
|
|
private $model;
|
|
|
|
/** @var int Timeout in seconds */
|
|
private $timeout;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $type The LLM type
|
|
* @param string $key API Key
|
|
* @param string $baseUrl API Base URL
|
|
* @param string $model Model name
|
|
* @param int $timeout Timeout in seconds
|
|
*/
|
|
public function __construct(string $type, string $key, string $baseUrl, string $model, int $timeout)
|
|
{
|
|
$this->type = strtolower($type);
|
|
$this->key = $key;
|
|
$this->baseUrl = rtrim($baseUrl, '/');
|
|
$this->model = $model;
|
|
$this->timeout = $timeout;
|
|
}
|
|
|
|
/**
|
|
* Generate a response using the configured LLM provider
|
|
*
|
|
* @param string $system The system prompt/instruction
|
|
* @param string $userMsg The specific user query
|
|
* @param string $mode 'json' for strict JSON (MCP), 'text' for legacy (default)
|
|
* @return string|null The text response from the AI or null on failure
|
|
*/
|
|
public function generate(string $system, string $userMsg, string $mode = 'text'): ?string
|
|
{
|
|
switch ($this->type) {
|
|
case 'anthropic':
|
|
return $this->callAnthropic($system, $userMsg, $mode);
|
|
case 'google':
|
|
return $this->callGoogle($system, $userMsg, $mode);
|
|
default:
|
|
return $this->callOpenAI($system, $userMsg, $mode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call OpenAI-compatible API
|
|
*
|
|
* @param string $sys System prompt
|
|
* @param string $msg User message
|
|
* @param string $mode 'json' or 'text'
|
|
* @return string|null Response content or null on failure
|
|
*/
|
|
private function callOpenAI(string $sys, string $msg, string $mode = 'text'): ?string
|
|
{
|
|
$url = $this->baseUrl;
|
|
if (strpos($url, '/chat/completions') === false && strpos($url, '/generate') === false) {
|
|
$url .= '/chat/completions';
|
|
}
|
|
|
|
$data = array(
|
|
"model" => $this->model,
|
|
"messages" => array(
|
|
array("role" => "system", "content" => $sys),
|
|
array("role" => "user", "content" => $msg)
|
|
),
|
|
"temperature" => 0.1
|
|
);
|
|
|
|
// Only force JSON mode if explicitly requested
|
|
// This allows Email/Webpage generation to return raw HTML
|
|
if ($mode === 'json') {
|
|
// Apply to specific providers known to support this parameter safely
|
|
if (strpos($url, 'openai') !== false || strpos($url, 'deepseek') !== false || strpos($url, 'perplexity') !== false || strpos($url, 'mistral') !== false || strpos($url, 'zai') !== false) {
|
|
$data["response_format"] = array("type" => "json_object");
|
|
}
|
|
}
|
|
|
|
$this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
|
|
|
|
return $this->curl($url, $data, array("Content-Type: application/json", "Authorization: Bearer " . $this->key));
|
|
}
|
|
|
|
/**
|
|
* Call Anthropic API (Claude)
|
|
*
|
|
* @param string $sys System prompt
|
|
* @param string $msg User message
|
|
* @param string $mode Response mode (default: text)
|
|
*
|
|
* @return string|null Response content or null on failure
|
|
*/
|
|
private function callAnthropic(string $sys, string $msg, string $mode = 'text')
|
|
{
|
|
|
|
$url = $this->baseUrl . (strpos($this->baseUrl, '/messages') === false ? '/messages' : '');
|
|
|
|
$data = array(
|
|
"model" => $this->model,
|
|
"system" => $sys,
|
|
"messages" => array(array("role" => "user", "content" => $msg)),
|
|
"max_tokens" => 1024
|
|
);
|
|
|
|
$this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
|
|
|
|
return $this->curl($url, $data, array("content-type: application/json", "x-api-key: " . $this->key, "anthropic-version: 2023-06-01"), true);
|
|
}
|
|
|
|
/**
|
|
* Call Google Gemini API
|
|
*
|
|
* @param string $sys System prompt
|
|
* @param string $msg User message
|
|
* @param string $mode Response mode (default: text)
|
|
*
|
|
* @return string|null Response content or null on failure
|
|
*/
|
|
private function callGoogle(string $sys, string $msg, string $mode = 'text')
|
|
{
|
|
$url = $this->baseUrl;
|
|
|
|
// Strict type check for string position
|
|
if (strpos($url, ':generateContent') === false) {
|
|
if (strpos($url, '/models/') === false) {
|
|
$url .= "/models/" . $this->model;
|
|
}
|
|
$url .= ":generateContent";
|
|
}
|
|
|
|
$url .= "?key=" . $this->key;
|
|
|
|
$data = array(
|
|
"contents" => array(
|
|
array("parts" => array(array("text" => $sys . "\nUser: " . $msg)))
|
|
),
|
|
"generationConfig" => array("temperature" => 0.1)
|
|
);
|
|
|
|
$this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
|
|
|
|
return $this->curl($url, $data, array("Content-Type: application/json"), false, true);
|
|
}
|
|
|
|
/**
|
|
* Execute HTTP Request via cURL
|
|
*
|
|
* @param string $url Target API URL
|
|
* @param array<string, mixed> $data Request payload (keys are strings, values vary)
|
|
* @param array<int, string> $headers List of HTTP headers (indexed array of strings)
|
|
* @param bool $isClaude Flag to handle Anthropic response format
|
|
* @param bool $isGemini Flag to handle Gemini response format
|
|
* @return string|null Returns the extracted text, an error message, or null
|
|
*/
|
|
private function curl(string $url, array $data, array $headers, bool $isClaude = false, bool $isGemini = false): ?string
|
|
{
|
|
include_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php';
|
|
|
|
// By default, we accept only external endpoints ($dolibarr_ai_allow_local_endpoints is not set).
|
|
// To allow local endpoints, we must set $dolibarr_ai_allow_local_endpoints to 1 or 2 in conf.php.
|
|
global $dolibarr_ai_allow_local_endpoints;
|
|
$localurl = $dolibarr_ai_allow_local_endpoints ?? 0;
|
|
|
|
// Pass $this->timeout as the response timeout so the LLM-specific value configured
|
|
// at construction time is honored (getURLContent's $timeoutresponse is the 10th arg;
|
|
// preceding args $ssl_verifypeer=-1 and $timeoutconnect=0 keep their defaults).
|
|
$result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl, -1, 0, $this->timeout);
|
|
|
|
$body = (string) ($result['content'] ?? '');
|
|
$httpCode = (int) ($result['http_code'] ?? 0);
|
|
$effectiveUrl = (string) ($result['url'] ?? $url);
|
|
// Store an enriched payload so the admin Log Viewer ("VIEW LOGS" in the AI Server
|
|
// MCP setup page) shows something actionable when something goes wrong, not just
|
|
// a bare "Invalid JSON response from API." with an empty body.
|
|
$this->lastResponse = "HTTP " . $httpCode . " from " . $effectiveUrl . "\n--- body (" . strlen($body) . " bytes) ---\n" . $body;
|
|
|
|
if (!empty($result['curl_error_no'])) {
|
|
return "Error: cURL #" . $result['curl_error_no'] . " " . $result['curl_error_msg'] . " (url=" . $effectiveUrl . ")";
|
|
}
|
|
|
|
$json = json_decode($body, true);
|
|
|
|
if ($json === null && json_last_error() !== JSON_ERROR_NONE) {
|
|
// Common real-world causes: HTTP 4xx/5xx with empty body, HTML error page
|
|
// from a proxy, gateway timeout, etc. Surface the HTTP code and a short
|
|
// body snippet so the admin can diagnose without re-running with curl.
|
|
$snippet = substr($body, 0, 500);
|
|
return "Error: Invalid JSON response from API (HTTP " . $httpCode . ", " . strlen($body) . " bytes). Body snippet: " . ($snippet !== '' ? $snippet : '<empty>');
|
|
}
|
|
|
|
if (isset($json['error'])) {
|
|
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
|
return "Error: API " . $msg;
|
|
}
|
|
|
|
// Extraction Logic
|
|
if ($isClaude) {
|
|
return $json['content'][0]['text'] ?? null;
|
|
}
|
|
if ($isGemini) {
|
|
return $json['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
|
}
|
|
|
|
// Default (OpenAI compatible)
|
|
return $json['choices'][0]['message']['content'] ?? null;
|
|
}
|
|
}
|