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 public $lastUsage = array();
37
39 private $type;
40
42 private $key;
43
45 private $baseUrl;
46
48 private $model;
49
51 private $timeout;
52
62 public function __construct(string $type, string $key, string $baseUrl, string $model, int $timeout)
63 {
64 $this->type = strtolower($type);
65 $this->key = $key;
66 $this->baseUrl = rtrim($baseUrl, '/');
67 $this->model = $model;
68 $this->timeout = $timeout;
69 }
70
85 public function generate(string $system, string $userMsg, string $mode = 'text', array $attachments = array(), array $history = array()): ?string
86 {
87 switch ($this->type) {
88 case 'anthropic':
89 return $this->callAnthropic($system, $userMsg, $mode, $attachments, $history);
90 case 'google':
91 return $this->callGoogle($system, $userMsg, $mode, $attachments, $history);
92 default:
93 return $this->callOpenAI($system, $userMsg, $mode, $attachments, $history);
94 }
95 }
96
107 private function callOpenAI(string $sys, string $msg, string $mode = 'text', array $attachments = array(), array $history = array()): ?string
108 {
109 $url = $this->baseUrl;
110 if (strpos($url, '/chat/completions') === false && strpos($url, '/generate') === false) {
111 $url .= '/chat/completions';
112 }
113
114 // With attachments, the user content becomes an array of typed parts
115 // (vision input); without, it stays a plain string (widest compatibility).
116 $userContent = $msg;
117 if (!empty($attachments)) {
118 $userContent = array(array("type" => "text", "text" => $msg));
119 foreach ($attachments as $att) {
120 if ($att['mime'] === 'application/pdf') {
121 // OpenAI compatible APIs reject non-image MIME inside image_url;
122 // PDFs use the dedicated 'file' content part.
123 $userContent[] = array(
124 "type" => "file",
125 "file" => array(
126 "filename" => "document.pdf",
127 "file_data" => "data:application/pdf;base64,".$att['data']
128 )
129 );
130 } else {
131 $userContent[] = array(
132 "type" => "image_url",
133 "image_url" => array("url" => "data:".$att['mime'].";base64,".$att['data'])
134 );
135 }
136 }
137 }
138
139 $messages = array(array("role" => "system", "content" => $sys));
140 foreach ($history as $turn) {
141 $messages[] = array("role" => (($turn['role'] ?? '') === 'assistant' ? 'assistant' : 'user'), "content" => (string) $turn['text']);
142 }
143 $messages[] = array("role" => "user", "content" => $userContent);
144
145 $data = array(
146 "model" => $this->model,
147 "messages" => $messages,
148 "temperature" => 0.1
149 );
150 if (!empty($attachments)) {
151 $data["max_tokens"] = 8192; // a multi-line document (e.g. a delivery note) serializes to an intent JSON far beyond 4096 tokens
152 }
153
154 // Only force JSON mode if explicitly requested
155 // This allows Email/Webpage generation to return raw HTML
156 if ($mode === 'json') {
157 // Apply to specific providers known to support this parameter safely
158 if (strpos($url, 'openai') !== false || strpos($url, 'deepseek') !== false || strpos($url, 'perplexity') !== false || strpos($url, 'mistral') !== false || strpos($url, 'zai') !== false) {
159 $data["response_format"] = array("type" => "json_object");
160 }
161 }
162
163 $this->lastRequest = $this->encodeRequestForLog($data);
164
165 return $this->curl($url, $data, array("Content-Type: application/json", "Authorization: Bearer " . $this->key));
166 }
167
179 private function callAnthropic(string $sys, string $msg, string $mode = 'text', array $attachments = array(), array $history = array())
180 {
181
182 $url = $this->baseUrl . (strpos($this->baseUrl, '/messages') === false ? '/messages' : '');
183
184 // With attachments, content becomes an array of typed blocks: PDFs go as
185 // 'document' blocks, images as 'image' blocks (Anthropic native formats).
186 $userContent = $msg;
187 $maxTokens = 1024;
188 if (!empty($attachments)) {
189 $userContent = array();
190 foreach ($attachments as $att) {
191 $userContent[] = array(
192 "type" => ($att['mime'] === 'application/pdf' ? "document" : "image"),
193 // Only application/pdf and image/* reach this point (see
194 // ai_validate_attachments()); anything else would 400.
195 "source" => array("type" => "base64", "media_type" => $att['mime'], "data" => $att['data'])
196 );
197 }
198 $userContent[] = array("type" => "text", "text" => $msg);
199 $maxTokens = 8192; // a multi-line document (e.g. a delivery note) serializes to an intent JSON far beyond 4096 tokens
200 }
201
202 $messages = array();
203 foreach ($history as $turn) {
204 $messages[] = array("role" => (($turn['role'] ?? '') === 'assistant' ? 'assistant' : 'user'), "content" => (string) $turn['text']);
205 }
206 $messages[] = array("role" => "user", "content" => $userContent);
207
208 $data = array(
209 "model" => $this->model,
210 "system" => $sys,
211 "messages" => $messages,
212 "max_tokens" => $maxTokens
213 );
214
215 $this->lastRequest = $this->encodeRequestForLog($data);
216
217 return $this->curl($url, $data, array("content-type: application/json", "x-api-key: " . $this->key, "anthropic-version: 2023-06-01"), true);
218 }
219
231 private function callGoogle(string $sys, string $msg, string $mode = 'text', array $attachments = array(), array $history = array())
232 {
233 $url = $this->baseUrl;
234
235 // Strict type check for string position
236 if (strpos($url, ':generateContent') === false) {
237 if (strpos($url, '/models/') === false) {
238 $url .= "/models/" . $this->model;
239 }
240 $url .= ":generateContent";
241 }
242
243 $url .= "?key=" . $this->key;
244
245 // With attachments, prepend native inline_data parts (Gemini vision /
246 // document understanding) before the text part.
247 $parts = array();
248 foreach ($attachments as $att) {
249 $parts[] = array("inline_data" => array("mime_type" => $att['mime'], "data" => $att['data']));
250 }
251 $parts[] = array("text" => $sys . "\nUser: " . $msg);
252
253 // Single-turn payload stays exactly as before (no 'role' key) so the
254 // historical behavior is untouched; only a non-empty history switches
255 // to Gemini's multi-turn format, where every content needs its role
256 // ('model' is Gemini's name for the assistant role).
257 $contents = array();
258 if (!empty($history)) {
259 foreach ($history as $turn) {
260 $contents[] = array(
261 "role" => (($turn['role'] ?? '') === 'assistant' ? 'model' : 'user'),
262 "parts" => array(array("text" => (string) $turn['text']))
263 );
264 }
265 $contents[] = array("role" => "user", "parts" => $parts);
266 } else {
267 $contents[] = array("parts" => $parts);
268 }
269
270 $data = array(
271 "contents" => $contents,
272 "generationConfig" => (empty($attachments) ? array("temperature" => 0.1) : array("temperature" => 0.1, "maxOutputTokens" => 16384)) // thinking models count their reasoning tokens INSIDE maxOutputTokens: at 4096 a multi-line reception intent came back finishReason=MAX_TOKENS, cut mid-JSON
273 );
274
275 $this->lastRequest = $this->encodeRequestForLog($data);
276
277 return $this->curl($url, $data, array("Content-Type: application/json"), false, true);
278 }
279
285 private function clearModelFailure()
286 {
287 global $db, $conf;
288
289 if (!is_object($db) || !is_object($conf)) {
290 return;
291 }
292 $raw = getDolGlobalString('AI_MODEL_RUNTIME_FAILURE');
293 if ($raw === '') {
294 return;
295 }
296 $rec = json_decode($raw, true);
297 if (!is_array($rec) || (string) ($rec['model'] ?? '') !== (string) $this->model) {
298 return; // a different model failed: leave that warning standing
299 }
300 include_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
301 dolibarr_del_const($db, 'AI_MODEL_RUNTIME_FAILURE', -1);
302 }
303
315 private function recordModelFailure(int $httpCode, string $msg)
316 {
317 global $db, $conf;
318
319 if (!is_object($db) || !is_object($conf)) {
320 return; // no Dolibarr runtime (defensive: adapter may be unit-tested standalone)
321 }
322 // Only errors that talk about the model itself, not quota/auth/network ones.
323 if (!preg_match('/model/i', $msg)) {
324 return;
325 }
326 if (!preg_match('/not.?found|does not exist|not exist|unsupported|not supported|not available|unavailable|deprecated|no longer|retired|invalid/i', $msg)) {
327 return;
328 }
329 include_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
330 dolibarr_set_const($db, 'AI_MODEL_RUNTIME_FAILURE', json_encode(array(
331 // The provider is recorded too: without it the banner survives a
332 // change of provider and blames a model nobody is using any more.
333 'service' => getDolGlobalString('AI_API_SERVICE'),
334 'model' => $this->model,
335 'ts' => dol_now(),
336 'http_code' => $httpCode,
337 'message' => dol_trunc($msg, 300)
338 )), 'chaine', 0, '', $conf->entity);
339 }
340
350 private function encodeRequestForLog(array $data)
351 {
352 $json = json_encode($data); // compact on purpose: the log budget is 60k chars, and core's json.lib.php polyfill makes 2-arg json_encode a phpstan error
353
354 // Unanchored: base64 appears both as bare JSON string values (Anthropic,
355 // Google) and embedded inside data: URLs (OpenAI file/image_url parts).
356 $out = preg_replace_callback(
357 '~((?:[A-Za-z0-9+=]++|\\\\/)+)~',
362 static function (array $m) {
363 if (strlen($m[1]) < 512) {
364 return $m[1]; // short runs (words, urls) stay as they are
365 }
366
367 return '[base64 elided, '.strlen($m[1]).' chars]';
368 },
369 $json
370 );
371
372 return ($out === null) ? $json : $out;
373 }
374
385 private function curl(string $url, array $data, array $headers, bool $isClaude = false, bool $isGemini = false): ?string
386 {
387 include_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php';
388
389 // By default, we accept only external endpoints ($dolibarr_ai_allow_local_endpoints is not set).
390 // To allow local endpoints, we must set $dolibarr_ai_allow_local_endpoints to 1 or 2 in conf.php.
391 global $dolibarr_ai_allow_local_endpoints;
392
393 $localurl = empty($dolibarr_ai_allow_local_endpoints) ? 0 : 2;
394
395 // Pass $this->timeout as the response timeout so the LLM-specific value configured
396 // at construction time is honored (getURLContent's $timeoutresponse is the 10th arg;
397 // preceding args $ssl_verifypeer=-1 and $timeoutconnect=0 keep their defaults).
398 $this->lastUsage = array(); // never carry over the previous call's usage
399
400 $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl, -1, 0, $this->timeout);
401
402 $body = (string) ($result['content'] ?? '');
403 $httpCode = (int) ($result['http_code'] ?? 0);
404 $effectiveUrl = (string) ($result['url'] ?? $url);
405 // The Gemini key travels as a ?key= query parameter: mask it before the
406 // URL lands in lastResponse, which is persisted into llx_ai_request_log
407 // and shown by the admin Log Viewer - a secret must never sit in a log.
408 $effectiveUrl = preg_replace('/([?&]key=)[^&\s]+/', '$1***', $effectiveUrl);
409 // Store an enriched payload so the admin Log Viewer ("VIEW LOGS" in the AI Server
410 // MCP setup page) shows something actionable when something goes wrong, not just
411 // a bare "Invalid JSON response from API." with an empty body.
412 $this->lastResponse = "HTTP " . $httpCode . " from " . $effectiveUrl . "\n--- body (" . strlen($body) . " bytes) ---\n" . $body;
413
414 if (!empty($result['curl_error_no'])) {
415 return "Error: cURL #" . $result['curl_error_no'] . " " . $result['curl_error_msg'] . " (url=" . $effectiveUrl . ")";
416 }
417
418 $json = json_decode($body, true);
419
420 if ($json === null && json_last_error() !== JSON_ERROR_NONE) {
421 // Common real-world causes: HTTP 4xx/5xx with empty body, HTML error page
422 // from a proxy, gateway timeout, etc. Surface the HTTP code and a short
423 // body snippet so the admin can diagnose without re-running with curl.
424 $snippet = substr($body, 0, 500);
425 return "Error: Invalid JSON response from API (HTTP " . $httpCode . ", " . strlen($body) . " bytes). Body snippet: " . ($snippet !== '' ? $snippet : '<empty>');
426 }
427
428 if (isset($json['error'])) {
429 $msg = $json['error']['message'] ?? json_encode($json['error']);
430 $this->recordModelFailure($httpCode, (string) $msg);
431 return "Error: API " . $msg;
432 }
433
434 // Token usage as reported by the provider, for the cost columns of the
435 // request log: every provider returns it inside the response body under
436 // its own name. Thinking tokens are billed as output, so Gemini's
437 // thoughtsTokenCount is counted with the visible candidates tokens.
438 // The call succeeded: a warning recorded for this same model is now stale.
439 $this->clearModelFailure();
440
441 $this->lastUsage = array('model' => $this->model);
442 if ($isGemini) {
443 $this->lastUsage['input'] = (int) ($json['usageMetadata']['promptTokenCount'] ?? 0);
444 $this->lastUsage['output'] = (int) ($json['usageMetadata']['candidatesTokenCount'] ?? 0) + (int) ($json['usageMetadata']['thoughtsTokenCount'] ?? 0);
445 } elseif ($isClaude) {
446 $this->lastUsage['input'] = (int) ($json['usage']['input_tokens'] ?? 0);
447 $this->lastUsage['output'] = (int) ($json['usage']['output_tokens'] ?? 0);
448 } else {
449 $this->lastUsage['input'] = (int) ($json['usage']['prompt_tokens'] ?? 0);
450 $this->lastUsage['output'] = (int) ($json['usage']['completion_tokens'] ?? 0);
451 }
452
453 // Extraction Logic
454 if ($isClaude) {
455 return $json['content'][0]['text'] ?? null;
456 }
457 if ($isGemini) {
458 return $json['candidates'][0]['content']['parts'][0]['text'] ?? null;
459 }
460
461 // Default (OpenAI compatible)
462 return $json['choices'][0]['message']['content'] ?? null;
463 }
464}
$propal type
'integer', 'integer:ObjectClass:PathToClass[:AddCreateButtonOrNot[:Filter[:Sortfield]]]',...
Definition propal.php:280
dolibarr_set_const($db, $name, $value, $type='chaine', $visible=0, $note='', $entity=1)
Insert a parameter (key,value) into database (delete old key then insert it again).
dolibarr_del_const($db, $name, $entity=1)
Delete a constant.
clearModelFailure()
Drop a recorded model failure once that model answers again.
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.
generate(string $system, string $userMsg, string $mode='text', array $attachments=array(), array $history=array())
Generate a response using the configured LLM provider.
recordModelFailure(int $httpCode, string $msg)
Record a "model not found / retired" type provider failure into the constant AI_MODEL_RUNTIME_FAILURE...
callGoogle(string $sys, string $msg, string $mode='text', array $attachments=array(), array $history=array())
Call Google Gemini API.
encodeRequestForLog(array $data)
JSON-encode a request for the log with base64 payloads removed, so the 60k truncation in ai_log_reque...
callAnthropic(string $sys, string $msg, string $mode='text', array $attachments=array(), array $history=array())
Call Anthropic API (Claude)
callOpenAI(string $sys, string $msg, string $mode='text', array $attachments=array(), array $history=array())
Call OpenAI-compatible API.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_now($mode='gmt')
Return date for now.
dol_trunc($string, $size=40, $trunc='right', $stringencoding='UTF-8', $nodot=0, $display=0)
Truncate a string to a particular length adding '...' if string larger than length.
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
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).