dolibarr 25.0.0-alpha
llmadapter.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2026 Nick Fragoulis
4 * Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY, without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 * or see https://www.gnu.org/
19 */
20
28{
30 public $lastRequest = "";
31
33 public $lastResponse = "";
34
36 private $type;
37
39 private $key;
40
42 private $baseUrl;
43
45 private $model;
46
48 private $timeout;
49
59 public function __construct(string $type, string $key, string $baseUrl, string $model, int $timeout)
60 {
61 $this->type = strtolower($type);
62 $this->key = $key;
63 $this->baseUrl = rtrim($baseUrl, '/');
64 $this->model = $model;
65 $this->timeout = $timeout;
66 }
67
81 public function generate(string $system, string $userMsg, string $mode = 'text', array $attachments = array()): ?string
82 {
83 switch ($this->type) {
84 case 'anthropic':
85 return $this->callAnthropic($system, $userMsg, $mode, $attachments);
86 case 'google':
87 return $this->callGoogle($system, $userMsg, $mode, $attachments);
88 default:
89 return $this->callOpenAI($system, $userMsg, $mode, $attachments);
90 }
91 }
92
102 private function callOpenAI(string $sys, string $msg, string $mode = 'text', array $attachments = array()): ?string
103 {
104 $url = $this->baseUrl;
105 if (strpos($url, '/chat/completions') === false && strpos($url, '/generate') === false) {
106 $url .= '/chat/completions';
107 }
108
109 // With attachments, the user content becomes an array of typed parts
110 // (vision input); without, it stays a plain string (widest compatibility).
111 $userContent = $msg;
112 if (!empty($attachments)) {
113 $userContent = array(array("type" => "text", "text" => $msg));
114 foreach ($attachments as $att) {
115 $userContent[] = array(
116 "type" => "image_url",
117 "image_url" => array("url" => "data:".$att['mime'].";base64,".$att['data'])
118 );
119 }
120 }
121
122 $data = array(
123 "model" => $this->model,
124 "messages" => array(
125 array("role" => "system", "content" => $sys),
126 array("role" => "user", "content" => $userContent)
127 ),
128 "temperature" => 0.1
129 );
130
131 // Only force JSON mode if explicitly requested
132 // This allows Email/Webpage generation to return raw HTML
133 if ($mode === 'json') {
134 // Apply to specific providers known to support this parameter safely
135 if (strpos($url, 'openai') !== false || strpos($url, 'deepseek') !== false || strpos($url, 'perplexity') !== false || strpos($url, 'mistral') !== false || strpos($url, 'zai') !== false) {
136 $data["response_format"] = array("type" => "json_object");
137 }
138 }
139
140 $this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
141
142 return $this->curl($url, $data, array("Content-Type: application/json", "Authorization: Bearer " . $this->key));
143 }
144
155 private function callAnthropic(string $sys, string $msg, string $mode = 'text', array $attachments = array())
156 {
157
158 $url = $this->baseUrl . (strpos($this->baseUrl, '/messages') === false ? '/messages' : '');
159
160 // With attachments, content becomes an array of typed blocks: PDFs go as
161 // 'document' blocks, images as 'image' blocks (Anthropic native formats).
162 $userContent = $msg;
163 $maxTokens = 1024;
164 if (!empty($attachments)) {
165 $userContent = array();
166 foreach ($attachments as $att) {
167 $userContent[] = array(
168 "type" => ($att['mime'] === 'application/pdf' ? "document" : "image"),
169 "source" => array("type" => "base64", "media_type" => $att['mime'], "data" => $att['data'])
170 );
171 }
172 $userContent[] = array("type" => "text", "text" => $msg);
173 $maxTokens = 4096; // document extraction answers are much longer than intent JSON
174 }
175
176 $data = array(
177 "model" => $this->model,
178 "system" => $sys,
179 "messages" => array(array("role" => "user", "content" => $userContent)),
180 "max_tokens" => $maxTokens
181 );
182
183 $this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
184
185 return $this->curl($url, $data, array("content-type: application/json", "x-api-key: " . $this->key, "anthropic-version: 2023-06-01"), true);
186 }
187
198 private function callGoogle(string $sys, string $msg, string $mode = 'text', array $attachments = array())
199 {
200 $url = $this->baseUrl;
201
202 // Strict type check for string position
203 if (strpos($url, ':generateContent') === false) {
204 if (strpos($url, '/models/') === false) {
205 $url .= "/models/" . $this->model;
206 }
207 $url .= ":generateContent";
208 }
209
210 $url .= "?key=" . $this->key;
211
212 // With attachments, prepend native inline_data parts (Gemini vision /
213 // document understanding) before the text part.
214 $parts = array();
215 foreach ($attachments as $att) {
216 $parts[] = array("inline_data" => array("mime_type" => $att['mime'], "data" => $att['data']));
217 }
218 $parts[] = array("text" => $sys . "\nUser: " . $msg);
219
220 $data = array(
221 "contents" => array(
222 array("parts" => $parts)
223 ),
224 "generationConfig" => array("temperature" => 0.1)
225 );
226
227 $this->lastRequest = json_encode($data, JSON_PRETTY_PRINT);
228
229 return $this->curl($url, $data, array("Content-Type: application/json"), false, true);
230 }
231
242 private function curl(string $url, array $data, array $headers, bool $isClaude = false, bool $isGemini = false): ?string
243 {
244 include_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php';
245
246 // By default, we accept only external endpoints ($dolibarr_ai_allow_local_endpoints is not set).
247 // To allow local endpoints, we must set $dolibarr_ai_allow_local_endpoints to 1 or 2 in conf.php.
248 global $dolibarr_ai_allow_local_endpoints;
249 $localurl = $dolibarr_ai_allow_local_endpoints ?? 0;
250
251 // Pass $this->timeout as the response timeout so the LLM-specific value configured
252 // at construction time is honored (getURLContent's $timeoutresponse is the 10th arg;
253 // preceding args $ssl_verifypeer=-1 and $timeoutconnect=0 keep their defaults).
254 $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl, -1, 0, $this->timeout);
255
256 $body = (string) ($result['content'] ?? '');
257 $httpCode = (int) ($result['http_code'] ?? 0);
258 $effectiveUrl = (string) ($result['url'] ?? $url);
259 // Store an enriched payload so the admin Log Viewer ("VIEW LOGS" in the AI Server
260 // MCP setup page) shows something actionable when something goes wrong, not just
261 // a bare "Invalid JSON response from API." with an empty body.
262 $this->lastResponse = "HTTP " . $httpCode . " from " . $effectiveUrl . "\n--- body (" . strlen($body) . " bytes) ---\n" . $body;
263
264 if (!empty($result['curl_error_no'])) {
265 return "Error: cURL #" . $result['curl_error_no'] . " " . $result['curl_error_msg'] . " (url=" . $effectiveUrl . ")";
266 }
267
268 $json = json_decode($body, true);
269
270 if ($json === null && json_last_error() !== JSON_ERROR_NONE) {
271 // Common real-world causes: HTTP 4xx/5xx with empty body, HTML error page
272 // from a proxy, gateway timeout, etc. Surface the HTTP code and a short
273 // body snippet so the admin can diagnose without re-running with curl.
274 $snippet = substr($body, 0, 500);
275 return "Error: Invalid JSON response from API (HTTP " . $httpCode . ", " . strlen($body) . " bytes). Body snippet: " . ($snippet !== '' ? $snippet : '<empty>');
276 }
277
278 if (isset($json['error'])) {
279 $msg = $json['error']['message'] ?? json_encode($json['error']);
280 return "Error: API " . $msg;
281 }
282
283 // Extraction Logic
284 if ($isClaude) {
285 return $json['content'][0]['text'] ?? null;
286 }
287 if ($isGemini) {
288 return $json['candidates'][0]['content']['parts'][0]['text'] ?? null;
289 }
290
291 // Default (OpenAI compatible)
292 return $json['choices'][0]['message']['content'] ?? null;
293 }
294}
curl(string $url, array $data, array $headers, bool $isClaude=false, bool $isGemini=false)
Execute HTTP Request via cURL.
__construct(string $type, string $key, string $baseUrl, string $model, int $timeout)
Constructor.
callOpenAI(string $sys, string $msg, string $mode='text', array $attachments=array())
Call OpenAI-compatible API.
callGoogle(string $sys, string $msg, string $mode='text', array $attachments=array())
Call Google Gemini API.
callAnthropic(string $sys, string $msg, string $mode='text', array $attachments=array())
Call Anthropic API (Claude)
generate(string $system, string $userMsg, string $mode='text', array $attachments=array())
Generate a response using the configured LLM provider.
getURLContent($url, $postorget='GET', $param='', $followlocation=1, $addheaders=array(), $allowedschemes=array('http', 'https'), $localurl=0, $ssl_verifypeer=-1, $timeoutconnect=0, $timeoutresponse=0, $otherCurlOptions=array(), $morelogsuffix='')
Function to get a content from an URL (use proxy if proxy defined).
print $langs trans("Show") . '< td style="' . $timeColor . '" align="center"> s</td > badge status0 badge status4 badge status3 Error badge status8< td align="center">< span class="badge ' . $badge . '"></span ></td >< td align="center">< a href="#" class="button button-small" onclick="openLogModal(this)" data-req="' . dol_escape_htmltag($reqSafe) . '" data-res="' . dol_escape_htmltag($resSafe) . '" data-err="' . dol_escape_htmltag($errSafe) . '">< span class="fa fa-search-plus"></span ></a ></td ></tr >< tr >< td colspan="' . $colspan . '" class="opacitymedium"></td ></tr ></table ></div ></form > logModal none logModal none s a JSON string
if(preg_match('/(crypted|dolcrypt):/i', $dolibarr_main_db_pass)||!empty($dolibarr_main_db_encrypted_pass)) $conf db type
'integer', 'integer:ObjectClass:PathToClass[:AddCreateButtonOrNot[:Filter[:Sortfield]]]',...
Definition repair.php:130