Просмотр исходного кода

Una: remote/proxy agent with key&secret auth (#5829)

## Goal

Client UNA sites get the existing AI operator chat, backed by one seeded local model and one seeded local agent. Inference is proxied to the master UNA site (unacms.com). The master alone chooses which Studio model to call and holds that model's key and parameters. Tool calls run on the client site, against the client's database.

The chat UI already exists. Do not add a new chat box. The operator block is `serviceGetBlockAiAgentOperator` in `template/scripts/BxBaseServices.php`. It reads `sys_agents_operator_agent` and streams `sys-ai-chat`. NEO uses the same endpoint. History, limits, ACL, and the Neuron agent loop stay on the client.

## Architecture

The client runs the existing Neuron loop in `inc/classes/BxDolAiAgent.php`. Tools are created by `inc/classes/BxDolAIToolFactory.php` and must keep running in that local process. Do not put the agent loop on the master. Do not have the master call back into the client to execute tools: many client sites are not reachable from unacms.com, and that would fork the Neuron loop.

The client's model is only an endpoint. It speaks OpenAI-compatible Chat Completions. It does not store a master model name, a provider key, or generation params. A placeholder `model` string may be sent because the OpenAI body requires one. The master discards `model` and every generation param in the body.

Neuron's OpenAI provider already posts `chat/completions` and parses the OpenAI stream (`plugins` package `neuron-core/neuron-ai`, class `NeuronAI\Providers\OpenAI\OpenAI`, method `createChatHttpRequest`). Reuse that client. Point it at:

`https://unacms.com/m/ai_proxy/v1/chat/completions`

Replace `Authorization: Bearer` with these headers. Do not send the secret:

- `X-Una-Key`: the client site's `sys_oauth_key` (Studio > Settings > System, the site UNA key)
- `X-Una-Sign`: `hash_hmac('sha256', raw request body, sys_oauth_secret)`

If either value is missing, fail that chat turn with the existing stream error before any tool runs. Exclude this model type from the empty-key check in `inc/classes/BxDolAIModelFactory.php`.

## Master module

Add `modules/boonex/ai_proxy/` (`bx_ai_proxy`). It is installed only on unacms.com. Do not enable it in the default client install.

One module setting: which active chat model (`chatllm` or `chatvlm`) serves the endpoint. Load that row with `BxDolAIModelFactory`, so its key and params stay on the master. The client request cannot select or override them.

`POST m/ai_proxy/v1/chat/completions` is public. It does not use a member session.

Authenticate the caller against OAuth clients on the master:

- Look up `bx_oauth_clients.client_id` = `X-Una-Key` via the OAuth2 module, `get_clients_by` with type `client_id`. That id is the same value as the client's `sys_oauth_key`.
- Recompute `hash_hmac('sha256', raw body, client_secret)` and compare with `hash_equals`. `client_secret` is the secret stored for that site on the master, the same value as the client's `sys_oauth_secret`.
- Unknown key or a bad hash is 401.
- Never return a provider key.

Read `messages`, `tools`, and `stream` from the OpenAI body. Ignore `model` and generation params. Run the configured Studio model. Write the answer back as OpenAI chat-completion chunks, including `tool_calls` and `data: [DONE]`, so the client's existing OpenAI stream parser can build a Neuron tool message. The master never executes a tool. After a tool call, the client runs it locally and sends the next completion.

## Seed data

Seed in `install/sql/system.sql`. For existing sites, put the same statements in the pull-request migration SQL. Do not add or edit anything under `upgrade/files/`.

- One active `sys_agents_models` row for the proxy. Empty `key`. No provider params. No master model name.
- One active agent named `una_operator`, trigger `manual`, `profile_id` from `sys_profile_bot`, `model_id` of that row. The prompt lives on this row. Tools are local and looked up by `sys_agents_tools.type`, not by numeric id: `content_structure`, `content_search`, `content_get`, `comments_get`. Do not attach `mysql_write`.
- Set `sys_agents_operator_agent` to this agent only when that option is still empty. A site that already chose an operator agent keeps it.

## Tests

Unit tests, no live provider call:

- HMAC: matching key and secret, wrong secret, empty key.
- A request body whose `model` is not the configured Studio model still uses the module setting.
- An OpenAI `tool_calls` chunk becomes a Neuron tool message on the client.
Aleksandr Trofimov 1 неделя назад
Родитель
Сommit
cfad910765
31 измененных файлов с 1648 добавлено и 3 удалено
  1. 9 1
      inc/classes/BxDolAIModelFactory.php
  2. 33 0
      inc/classes/BxDolAIProviderUnaProxy.php
  3. 67 0
      inc/classes/BxDolAIUnaProxyHttpClient.php
  4. 24 0
      inc/classes/BxDolAiAgent.php
  5. 5 0
      inc/classes/BxDolAiQuery.php
  6. 20 0
      inc/classes/BxDolAiRetrievalNone.php
  7. 25 1
      inc/classes/BxDolAiTrigger.php
  8. 1 1
      inc/classes/BxDolAiTriggerChat.php
  9. 26 0
      install/sql/addon.sql
  10. 67 0
      modules/boonex/ai_proxy/classes/BxAiProxyAuth.php
  11. 506 0
      modules/boonex/ai_proxy/classes/BxAiProxyCompletion.php
  12. 20 0
      modules/boonex/ai_proxy/classes/BxAiProxyConfig.php
  13. 20 0
      modules/boonex/ai_proxy/classes/BxAiProxyDb.php
  14. 158 0
      modules/boonex/ai_proxy/classes/BxAiProxyModule.php
  15. 73 0
      modules/boonex/ai_proxy/install/config.php
  16. 20 0
      modules/boonex/ai_proxy/install/installer.php
  17. 6 0
      modules/boonex/ai_proxy/install/langs/en.xml
  18. 6 0
      modules/boonex/ai_proxy/install/langs/ru.xml
  19. 4 0
      modules/boonex/ai_proxy/install/sql/disable.sql
  20. 14 0
      modules/boonex/ai_proxy/install/sql/enable.sql
  21. 14 0
      modules/boonex/ai_proxy/install/sql/install.sql
  22. 6 0
      modules/boonex/ai_proxy/install/sql/uninstall.sql
  23. 38 0
      modules/boonex/ai_proxy/request.php
  24. 3 0
      modules/boonex/ai_proxy/template/images/icons/std-icon.svg
  25. 3 0
      modules/boonex/english/data/langs/system/en.xml
  26. 3 0
      modules/boonex/russian/data/langs/system/ru.xml
  27. 2 0
      scripts/studio_icon_from_lucide.py
  28. 174 0
      tests/units/inc/classes/BxDolAIProviderUnaProxyTest.php
  29. 61 0
      tests/units/inc/classes/BxDolAiAgentRetrievalTest.php
  30. 21 0
      tests/units/inc/classes/BxDolAiTriggerTest.php
  31. 219 0
      tests/units/modules/boonex/ai_proxy/classes/BxAiProxyTest.php

+ 9 - 1
inc/classes/BxDolAIModelFactory.php

@@ -27,7 +27,7 @@ class BxDolAIModelFactory extends BxDolFactory
             throw new Exception("Model with id {$iId} is a judge model, it can't be used as a chat/embeddings provider");
         }
 
-        if ($iMaxTokensOverride > 0) {
+        if ($iMaxTokensOverride > 0 && ($a['type'] ?? '') !== 'una-proxy') {
             $aParameters['max_tokens'] = $iMaxTokensOverride;
             if (!isset($aParameters['parameters']) || !is_array($aParameters['parameters']))
                 $aParameters['parameters'] = [];
@@ -163,6 +163,13 @@ class BxDolAIModelFactory extends BxDolFactory
                     strict_response: $aParameters['strict_response'] ?? false,
                 );
                 break;
+            case 'una-proxy':
+                $o = new BxDolAIProviderUnaProxy(
+                    sKey: (string)getParam('sys_oauth_key'),
+                    sSecret: (string)getParam('sys_oauth_secret'),
+                    sModel: (string)$a['model'],
+                );
+                break;
 
             // embeddings models ------------------------
             case 'ollama-embeddings':
@@ -247,6 +254,7 @@ class BxDolAIModelFactory extends BxDolFactory
      */
     protected static function _getModel(int $iId): array
     {
+        // una-proxy signs with the site UNA key. An empty provider key is expected.
         $aProvidersWithKey = ['anthropic', 'openai-embeddings', 'voyageai-embeddings', 'openai-like-embeddings', 'openai-responses', 'openai-like', 'typesafe'];
         $a = BxDolAiQuery::getModelObject($iId);
         if (!$a) {

+ 33 - 0
inc/classes/BxDolAIProviderUnaProxy.php

@@ -0,0 +1,33 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIProviderUnaProxy extends NeuronAI\Providers\OpenAI\OpenAI
+{
+    protected string $baseUri = 'https://unacms.com/m/ai_proxy/v1';
+
+    public function __construct(
+        string $sKey,
+        string $sSecret,
+        string $sModel,
+        ?NeuronAI\HttpClient\HttpClientInterface $httpClient = null,
+    ) {
+        if ($sKey === '' || $sSecret === '')
+            throw new Exception(_t('_sys_agents_una_proxy_unconfigured'));
+
+        parent::__construct(
+            key: '',
+            model: $sModel,
+            parameters: [],
+            strict_response: false,
+            httpClient: $httpClient ?? new BxDolAIUnaProxyHttpClient($sKey, $sSecret),
+        );
+    }
+}
+
+/** @} */

+ 67 - 0
inc/classes/BxDolAIUnaProxyHttpClient.php

@@ -0,0 +1,67 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIUnaProxyHttpClient extends NeuronAI\HttpClient\GuzzleHttpClient
+{
+    public function __construct(
+        protected string $sUnaKey,
+        protected string $sUnaSecret,
+    ) {
+        parent::__construct();
+    }
+
+    public static function encodeBody(array $aBody): string
+    {
+        return json_encode($aBody, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+    }
+
+    public static function sign(string $sRawBody, string $sSecret): string
+    {
+        return hash_hmac('sha256', $sRawBody, $sSecret);
+    }
+
+    /**
+     * Headers for the exact bytes that will be posted. The secret is not a header.
+     *
+     * @return array<string, string>
+     */
+    public function headersForBody(string $sRawBody): array
+    {
+        if ($this->sUnaKey === '' || $this->sUnaSecret === '')
+            throw new Exception(_t('_sys_agents_una_proxy_unconfigured'));
+
+        return [
+            'X-Una-Key' => $this->sUnaKey,
+            'X-Una-Sign' => self::sign($sRawBody, $this->sUnaSecret),
+        ];
+    }
+
+    public function runRequest(NeuronAI\HttpClient\HttpRequest $request, array $options, GuzzleHttp\Client $client): Psr\Http\Message\ResponseInterface
+    {
+        $sBody = '';
+        if (is_array($request->body)) {
+            $sBody = self::encodeBody($request->body);
+            $request = new NeuronAI\HttpClient\HttpRequest($request->method, $request->uri, $request->headers, $sBody);
+        }
+        elseif (is_string($request->body))
+            $sBody = $request->body;
+
+        $aHeaders = $options[\GuzzleHttp\RequestOptions::HEADERS] ?? [];
+        foreach (array_keys($aHeaders) as $sName) {
+            if (strcasecmp((string)$sName, 'Authorization') === 0)
+                unset($aHeaders[$sName]);
+        }
+        $options[\GuzzleHttp\RequestOptions::HEADERS] = array_merge($aHeaders, $this->headersForBody($sBody));
+        unset($options[\GuzzleHttp\RequestOptions::JSON]);
+
+        return parent::runRequest($request, $options, $client);
+    }
+}
+
+/** @} */

+ 24 - 0
inc/classes/BxDolAiAgent.php

@@ -101,6 +101,30 @@ class BxDolAiAgent extends RAG
             return new NeuronAI\RAG\VectorStore\MemoryVectorStore();
     }
 
+    /**
+     * Retrieval is built for every chat. The embeddings provider is set only
+     * when the agent has a knowledge store with an embedding model.
+     */
+    protected function retrieval(): NeuronAI\RAG\Retrieval\RetrievalInterface
+    {
+        if (!isset($this->embeddingsProvider)) {
+            if ((int)($this->aAgent['vector_store_id'] ?? 0) > 0)
+                throw new Exception("Vector store {$this->aAgent['vector_store_id']} has no embedding model");
+
+            return new BxDolAiRetrievalNone();
+        }
+
+        return new NeuronAI\RAG\Retrieval\SimilarityRetrieval(
+            $this->resolveVectorStore(),
+            $this->resolveEmbeddingsProvider()
+        );
+    }
+
+    protected function embeddings(): NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+    {
+        throw new Exception('Embedding model is not configured');
+    }
+
     protected function chatHistory(): NeuronAI\Chat\History\ChatHistoryInterface
     {
         if ($this->aAgent['chat_history_context']) {

+ 5 - 0
inc/classes/BxDolAiQuery.php

@@ -90,6 +90,11 @@ class BxDolAiQuery extends BxDolDb
                         $sWhereClause .= " AND `capabilities` = :capabilities";
                     }
                 }
+
+                if (!empty($aParams['type_not'])) {
+                    $aMethod['params'][3]['type_not'] = $aParams['type_not'];
+                    $sWhereClause .= " AND `type` <> :type_not";
+                }
                 break;
         }
 

+ 20 - 0
inc/classes/BxDolAiRetrievalNone.php

@@ -0,0 +1,20 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Retrieval for an agent that has no knowledge store.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiRetrievalNone implements NeuronAI\RAG\Retrieval\RetrievalInterface
+{
+    public function retrieve(NeuronAI\Chat\Messages\Message $query): array
+    {
+        return [];
+    }
+}
+
+/** @} */

+ 25 - 1
inc/classes/BxDolAiTrigger.php

@@ -107,12 +107,36 @@ class BxDolAiTrigger extends BxDol implements iBxDolAiTrigger
             $mixed = $oMessage->getContent();
         } catch (Exception $exception) {
             bx_log('sys_agents', "Exception in '{$aAgent['name']}' agent: " . $exception->getMessage() . " INPUT:" . $sParams, BX_LOG_ERR);
-            $mixed = _t('_sys_agents_exception');
+            $mixed = isAdmin() ? self::visibleError($exception) : _t('_sys_agents_exception');
         }
 
         return $mixed;
     }
 
+    /**
+     * Provider failures keep their full text in the log. Operators see the
+     * provider's own message, such as an authentication error.
+     */
+    public static function visibleError(Throwable $oException): string
+    {
+        $s = trim($oException->getMessage());
+        if ($s === '')
+            return _t('_sys_agents_exception');
+
+        $iJson = bx_mb_strpos($s, '{');
+        if ($iJson !== false) {
+            $sJson = get_mb_substr($s, $iJson, get_mb_len($s) - $iJson);
+            $a = json_decode($sJson, true);
+            if (is_array($a)) {
+                $sMessage = trim((string)($a['error']['message'] ?? ''));
+                if ($sMessage !== '')
+                    return $sMessage;
+            }
+        }
+
+        return $s;
+    }
+
     protected function getAi()
     {
         return BxDolAi::getInstance();

+ 1 - 1
inc/classes/BxDolAiTriggerChat.php

@@ -321,7 +321,7 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
                 foreach ($oAdapter->start() as $sEvent)
                     $fEmit($sEvent);
             }
-            $sMsg = $oException->getMessage();
+            $sMsg = isAdmin() ? self::visibleError($oException) : $oException->getMessage();
             $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
             foreach ($oAdapter->end() as $sEvent)
                 $fEmit($sEvent);

+ 26 - 0
install/sql/addon.sql

@@ -38,4 +38,30 @@ UPDATE `sys_options` SET `VALUE` = {oauth_key} WHERE `Name` = 'sys_oauth_key';
 UPDATE `sys_options` SET `VALUE` = {oauth_secret} WHERE `Name` = 'sys_oauth_secret';
 UPDATE `sys_options` SET `VALUE` = @iProfileIdBot WHERE `Name` = 'sys_profile_bot';
 
+-- UNA operator proxy: the master holds the provider key.
+INSERT INTO `sys_agents_models` SET
+    `type` = 'una-proxy',
+    `model` = 'una-proxy',
+    `title` = 'UNA proxy',
+    `duplicate` = 0;
+SET @iUnaProxyModel = LAST_INSERT_ID();
+
+INSERT INTO `sys_agents_agents` SET
+    `name` = 'una_operator',
+    `title` = 'UNA operator',
+    `model_id` = @iUnaProxyModel,
+    `profile_id` = @iProfileIdBot,
+    `prompt_system` = 'You are the site operator assistant. Answer questions about this site content. Use content_structure, content_search, content_get, and comments_get. Do not change data.',
+    `tools` = CONCAT_WS(',',
+        (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_structure' ORDER BY `id` LIMIT 1),
+        (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_search' ORDER BY `id` LIMIT 1),
+        (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_get' ORDER BY `id` LIMIT 1),
+        (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'comments_get' ORDER BY `id` LIMIT 1)
+    ),
+    `trigger` = 'manual',
+    `added` = {current_timestamp},
+    `active` = 1;
+
+UPDATE `sys_options` SET `VALUE` = LAST_INSERT_ID() WHERE `Name` = 'sys_agents_operator_agent';
+
 UPDATE `sys_modules` SET `version` = {version} WHERE `name` = 'system';

+ 67 - 0
modules/boonex/ai_proxy/classes/BxAiProxyAuth.php

@@ -0,0 +1,67 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyAuth
+{
+    /**
+     * @param callable(string): ?array $fLookup client_id to an OAuth client row. Not called when the key is empty.
+     * @return array{ok:bool, code:int}
+     */
+    public function authenticate(string $sKey, string $sSign, string $sBody, callable $fLookup): array
+    {
+        if ($sKey === '')
+            return $this->denied();
+
+        $mixedClient = $fLookup($sKey);
+        $sSecret = is_array($mixedClient) ? (string)($mixedClient['client_secret'] ?? '') : '';
+        if ($sSecret === '')
+            return $this->denied();
+
+        $sExpected = hash_hmac('sha256', $sBody, $sSecret);
+        try {
+            $bMatch = hash_equals($sExpected, $sSign);
+        }
+        catch (Throwable $oException) {
+            $bMatch = false;
+        }
+        if (!$bMatch)
+            return $this->denied();
+
+        return ['ok' => true, 'code' => 200];
+    }
+
+    /**
+     * @return array{ok:bool, code:int}
+     */
+    public function authenticateRequest(string $sKey, string $sSign, string $sBody): array
+    {
+        return $this->authenticate($sKey, $sSign, $sBody, function (string $sClientId) {
+            if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_oauth'))
+                return null;
+
+            $mixed = BxDolService::call('bx_oauth', 'get_clients_by', [[
+                'type' => 'client_id',
+                'client_id' => $sClientId,
+            ]]);
+            return is_array($mixed) ? $mixed : null;
+        });
+    }
+
+    /**
+     * @return array{ok:bool, code:int}
+     */
+    protected function denied(): array
+    {
+        return ['ok' => false, 'code' => 401];
+    }
+}
+
+/** @} */

+ 506 - 0
modules/boonex/ai_proxy/classes/BxAiProxyCompletion.php

@@ -0,0 +1,506 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyException extends Exception
+{
+    public function __construct(protected int $iHttpCode, string $sMessage)
+    {
+        parent::__construct($sMessage);
+    }
+
+    public function httpCode(): int
+    {
+        return $this->iHttpCode;
+    }
+}
+
+class BxAiProxyCompletion
+{
+    /**
+     * Studio model id for this turn. The request body model is ignored.
+     */
+    public function providerId(array $aBody, int $iConfiguredId): int
+    {
+        if ($iConfiguredId <= 0)
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+
+        return $iConfiguredId;
+    }
+
+    /**
+     * @param callable(int): NeuronAI\Providers\AIProviderInterface $fFactory
+     * @param callable(string): void|null $fEmit
+     */
+    public function run(array $aBody, int $iConfiguredId, callable $fFactory, ?callable $fEmit = null): ?string
+    {
+        [$oProvider, $aMessages] = $this->prepare($aBody, $iConfiguredId, $fFactory);
+        $sId = 'chatcmpl-' . bin2hex(random_bytes(8));
+        if (!empty($aBody['stream'])) {
+            $this->streamTo($oProvider, $aMessages, $sId, $fEmit ?? static function (string $sEvent) {
+            });
+            return null;
+        }
+
+        try {
+            $oMessage = $oProvider->chat(...$aMessages);
+        }
+        catch (BxAiProxyException $oException) {
+            throw $oException;
+        }
+        catch (Throwable $oException) {
+            $this->logProvider($oException);
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+        }
+
+        if (!$oMessage instanceof NeuronAI\Chat\Messages\Message)
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+
+        return json_encode($this->completionArray($sId, $oMessage), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+    }
+
+    /**
+     * @param callable(int): NeuronAI\Providers\AIProviderInterface $fFactory
+     * @return array{0: NeuronAI\Providers\AIProviderInterface, 1: list<NeuronAI\Chat\Messages\Message>}
+     */
+    protected function prepare(array $aBody, int $iConfiguredId, callable $fFactory): array
+    {
+        $iId = $this->providerId($aBody, $iConfiguredId);
+        try {
+            $oProvider = $fFactory($iId);
+        }
+        catch (BxAiProxyException $oException) {
+            throw $oException;
+        }
+        catch (Throwable $oException) {
+            $this->logProvider($oException);
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+        }
+
+        if (!$oProvider instanceof NeuronAI\Providers\AIProviderInterface)
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+
+        $aMapped = $this->mapBody($aBody);
+        if ($aMapped['system'] !== '')
+            $oProvider->systemPrompt($aMapped['system']);
+        if ($aMapped['tools'] !== [])
+            $oProvider->setTools($aMapped['tools']);
+        if ($aMapped['messages'] === [])
+            throw new BxAiProxyException(400, 'Bad request');
+
+        return [$oProvider, $aMapped['messages']];
+    }
+
+    /**
+     * @return array{system: string, messages: list<NeuronAI\Chat\Messages\Message>, tools: list<NeuronAI\Tools\Tool>}
+     */
+    protected function mapBody(array $aBody): array
+    {
+        if (!isset($aBody['messages']) || !is_array($aBody['messages']))
+            throw new BxAiProxyException(400, 'Bad request');
+
+        $aSystem = [];
+        $aMessages = [];
+        $aCallNames = [];
+        foreach ($aBody['messages'] as $aMessage) {
+            if (!is_array($aMessage))
+                continue;
+
+            $sRole = (string)($aMessage['role'] ?? '');
+            if ($sRole === 'system' || $sRole === 'developer') {
+                $sText = $this->textOf($aMessage['content'] ?? '');
+                if ($sText !== '')
+                    $aSystem[] = $sText;
+                continue;
+            }
+
+            if ($sRole === 'tool') {
+                $sCallId = (string)($aMessage['tool_call_id'] ?? '');
+                $sName = (string)($aMessage['name'] ?? ($aCallNames[$sCallId] ?? 'tool'));
+                if ($sName === '')
+                    $sName = 'tool';
+                $oTool = NeuronAI\Tools\Tool::make($sName, '');
+                if ($sCallId !== '')
+                    $oTool->setCallId($sCallId);
+                $oTool->setResult($this->textOf($aMessage['content'] ?? ''));
+                $aMessages[] = new NeuronAI\Chat\Messages\ToolResultMessage([$oTool]);
+                continue;
+            }
+
+            if ($sRole === 'assistant' && !empty($aMessage['tool_calls']) && is_array($aMessage['tool_calls'])) {
+                $aCalls = [];
+                foreach ($aMessage['tool_calls'] as $aCall) {
+                    if (!is_array($aCall))
+                        continue;
+                    $oTool = $this->callTool($aCall);
+                    $sCallId = (string)($oTool->getCallId() ?? '');
+                    if ($sCallId !== '')
+                        $aCallNames[$sCallId] = $oTool->getName();
+                    $aCalls[] = $oTool;
+                }
+                if ($aCalls === [])
+                    continue;
+                $aMessages[] = new NeuronAI\Chat\Messages\ToolCallMessage($this->contentBlocks($aMessage['content'] ?? null), $aCalls);
+                continue;
+            }
+
+            if ($sRole === 'assistant') {
+                $aMessages[] = new NeuronAI\Chat\Messages\AssistantMessage($this->contentBlocks($aMessage['content'] ?? ''));
+                continue;
+            }
+
+            if ($sRole === 'user')
+                $aMessages[] = new NeuronAI\Chat\Messages\UserMessage($this->contentBlocks($aMessage['content'] ?? ''));
+        }
+
+        return [
+            'system' => implode("\n\n", $aSystem),
+            'messages' => $aMessages,
+            'tools' => $this->definedTools($aBody),
+        ];
+    }
+
+    /**
+     * @return list<NeuronAI\Tools\Tool>
+     */
+    protected function definedTools(array $aBody): array
+    {
+        if (empty($aBody['tools']) || !is_array($aBody['tools']))
+            return [];
+
+        $aTools = [];
+        foreach ($aBody['tools'] as $aTool) {
+            if (!is_array($aTool))
+                continue;
+            $aFunction = isset($aTool['function']) && is_array($aTool['function']) ? $aTool['function'] : $aTool;
+            $sName = (string)($aFunction['name'] ?? '');
+            if ($sName === '')
+                continue;
+            $aParameters = isset($aFunction['parameters']) && is_array($aFunction['parameters']) ? $aFunction['parameters'] : [];
+            $aTools[] = $this->stubTool($sName, (string)($aFunction['description'] ?? ''), $aParameters);
+        }
+        return $aTools;
+    }
+
+    protected function stubTool(string $sName, string $sDescription, array $aParameters): NeuronAI\Tools\Tool
+    {
+        $oTool = NeuronAI\Tools\Tool::make($sName, $sDescription);
+        foreach ($this->schemaProperties($aParameters) as $oProperty)
+            $oTool->addProperty($oProperty);
+        return $oTool;
+    }
+
+    /**
+     * @return list<NeuronAI\Tools\ToolPropertyInterface>
+     */
+    protected function schemaProperties(array $aSchema): array
+    {
+        $aProperties = isset($aSchema['properties']) && is_array($aSchema['properties']) ? $aSchema['properties'] : [];
+        $aRequired = isset($aSchema['required']) && is_array($aSchema['required']) ? $aSchema['required'] : [];
+        $aBuilt = [];
+        foreach ($aProperties as $sProp => $aProperty) {
+            if (!is_string($sProp) || !is_array($aProperty))
+                continue;
+            $aBuilt[] = $this->schemaProperty($sProp, $aProperty, in_array($sProp, $aRequired, true));
+        }
+        return $aBuilt;
+    }
+
+    protected function schemaProperty(string $sName, array $aSchema, bool $bRequired): NeuronAI\Tools\ToolPropertyInterface
+    {
+        $sType = $aSchema['type'] ?? 'string';
+        try {
+            $oType = NeuronAI\Tools\PropertyType::fromSchema(is_array($sType) ? $sType : (string)$sType);
+        }
+        catch (Throwable $oException) {
+            $oType = NeuronAI\Tools\PropertyType::STRING;
+        }
+        $sDescription = isset($aSchema['description']) ? (string)$aSchema['description'] : null;
+
+        if ($oType === NeuronAI\Tools\PropertyType::ARRAY)
+            return $this->arrayProperty($sName, $aSchema, $bRequired, $sDescription);
+        if ($oType === NeuronAI\Tools\PropertyType::OBJECT)
+            return $this->objectProperty($sName, $aSchema, $bRequired, $sDescription);
+
+        $aEnum = isset($aSchema['enum']) && is_array($aSchema['enum']) ? array_values($aSchema['enum']) : [];
+        return new NeuronAI\Tools\ToolProperty($sName, $oType, $sDescription, $bRequired, $aEnum);
+    }
+
+    protected function arrayProperty(string $sName, array $aSchema, bool $bRequired, ?string $sDescription): NeuronAI\Tools\ArrayProperty
+    {
+        $oItems = null;
+        if (isset($aSchema['items']) && is_array($aSchema['items']) && !array_is_list($aSchema['items']))
+            $oItems = $this->schemaProperty($sName . '_item', $aSchema['items'], false);
+
+        $iMin = isset($aSchema['minItems']) && is_int($aSchema['minItems']) && $aSchema['minItems'] >= 0 ? $aSchema['minItems'] : null;
+        $iMax = isset($aSchema['maxItems']) && is_int($aSchema['maxItems']) && $aSchema['maxItems'] >= 0 ? $aSchema['maxItems'] : null;
+        if ($iMin !== null && $iMax !== null && $iMin > $iMax) {
+            $iMin = null;
+            $iMax = null;
+        }
+
+        return new NeuronAI\Tools\ArrayProperty($sName, $sDescription, $bRequired, $oItems, $iMin, $iMax);
+    }
+
+    protected function objectProperty(string $sName, array $aSchema, bool $bRequired, ?string $sDescription): NeuronAI\Tools\ObjectProperty
+    {
+        return new NeuronAI\Tools\ObjectProperty($sName, $sDescription, $bRequired, null, $this->schemaProperties($aSchema));
+    }
+
+    protected function callTool(array $aCall): NeuronAI\Tools\Tool
+    {
+        $aFunction = isset($aCall['function']) && is_array($aCall['function']) ? $aCall['function'] : [];
+        $sName = (string)($aFunction['name'] ?? 'tool');
+        if ($sName === '')
+            $sName = 'tool';
+        $oTool = NeuronAI\Tools\Tool::make($sName, '');
+        $sId = (string)($aCall['id'] ?? '');
+        if ($sId !== '')
+            $oTool->setCallId($sId);
+
+        $mixedArgs = $aFunction['arguments'] ?? [];
+        if (is_string($mixedArgs)) {
+            $mixedDecoded = json_decode($mixedArgs, true);
+            $mixedArgs = is_array($mixedDecoded) ? $mixedDecoded : [];
+        }
+        elseif (!is_array($mixedArgs))
+            $mixedArgs = [];
+        $oTool->setInputs($mixedArgs);
+        return $oTool;
+    }
+
+    protected function contentBlocks(mixed $mixed): string|array|null
+    {
+        if (is_string($mixed) || $mixed === null)
+            return $mixed;
+        if (!is_array($mixed) || $mixed === [])
+            return '';
+        if (!array_is_list($mixed))
+            $mixed = [$mixed];
+
+        $aBlocks = [];
+        foreach ($mixed as $aBlock) {
+            if (!is_array($aBlock))
+                continue;
+            $oBlock = $this->contentBlock($aBlock);
+            if ($oBlock)
+                $aBlocks[] = $oBlock;
+        }
+        return $aBlocks === [] ? '' : $aBlocks;
+    }
+
+    protected function contentBlock(array $aBlock): ?NeuronAI\Chat\Messages\ContentBlocks\ContentBlockInterface
+    {
+        $sType = (string)($aBlock['type'] ?? '');
+        if ($sType === 'text' || ($sType === '' && isset($aBlock['text'])))
+            return new NeuronAI\Chat\Messages\ContentBlocks\TextContent((string)($aBlock['text'] ?? ''));
+
+        if ($sType !== 'image_url')
+            return null;
+
+        $sUrl = '';
+        if (isset($aBlock['image_url']) && is_array($aBlock['image_url']))
+            $sUrl = (string)($aBlock['image_url']['url'] ?? '');
+        elseif (isset($aBlock['image_url']))
+            $sUrl = (string)$aBlock['image_url'];
+        if ($sUrl === '')
+            return null;
+
+        if (preg_match('#^data:([^;]+);base64,(.+)$#s', $sUrl, $aMatch))
+            return new NeuronAI\Chat\Messages\ContentBlocks\ImageContent($aMatch[2], NeuronAI\Chat\Enums\SourceType::BASE64, $aMatch[1]);
+
+        return new NeuronAI\Chat\Messages\ContentBlocks\ImageContent($sUrl, NeuronAI\Chat\Enums\SourceType::URL);
+    }
+
+    protected function textOf(mixed $mixed): string
+    {
+        if (is_string($mixed))
+            return $mixed;
+        if (!is_array($mixed))
+            return '';
+        if (!array_is_list($mixed))
+            $mixed = [$mixed];
+
+        $aParts = [];
+        foreach ($mixed as $aBlock) {
+            if (is_string($aBlock))
+                $aParts[] = $aBlock;
+            elseif (is_array($aBlock))
+                $aParts[] = (string)($aBlock['text'] ?? '');
+        }
+        return implode('', $aParts);
+    }
+
+    /**
+     * @param list<NeuronAI\Chat\Messages\Message> $aMessages
+     */
+    protected function streamTo(NeuronAI\Providers\AIProviderInterface $oProvider, array $aMessages, string $sId, callable $fEmit): void
+    {
+        $bText = false;
+        try {
+            $oGen = $oProvider->stream(...$aMessages);
+            foreach ($oGen as $oChunk) {
+                if ($oChunk instanceof NeuronAI\Chat\Messages\Stream\Chunks\TextChunk && $oChunk->content !== '') {
+                    $bText = true;
+                    $fEmit($this->contentDelta($sId, $oChunk->content));
+                }
+            }
+            $oMessage = $oGen->getReturn();
+        }
+        catch (BxAiProxyException $oException) {
+            throw $oException;
+        }
+        catch (Throwable $oException) {
+            $this->logProvider($oException);
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+        }
+
+        if (!$oMessage instanceof NeuronAI\Chat\Messages\Message)
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+
+        if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage) {
+            $this->emitToolCalls($sId, $oMessage, $fEmit);
+        }
+        else {
+            if (!$bText) {
+                $sContent = (string)$oMessage->getContent();
+                if ($sContent !== '')
+                    $fEmit($this->contentDelta($sId, $sContent));
+            }
+            $fEmit($this->sse([
+                'id' => $sId,
+                'object' => 'chat.completion.chunk',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => new stdClass(),
+                    'finish_reason' => 'stop',
+                ]],
+            ]));
+        }
+        $fEmit("data: [DONE]\n\n");
+    }
+
+    protected function emitToolCalls(string $sId, NeuronAI\Chat\Messages\ToolCallMessage $oMessage, callable $fEmit): void
+    {
+        $iIndex = 0;
+        foreach ($oMessage->getTools() as $oTool) {
+            $sCallId = (string)($oTool->getCallId() ?? ('call_' . $iIndex));
+            $aInputs = $oTool->getInputs();
+            $sArgs = json_encode($aInputs === [] ? new stdClass() : $aInputs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+            $fEmit($this->sse([
+                'id' => $sId,
+                'object' => 'chat.completion.chunk',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => [
+                        'tool_calls' => [[
+                            'index' => $iIndex,
+                            'id' => $sCallId,
+                            'type' => 'function',
+                            'function' => [
+                                'name' => $oTool->getName(),
+                                'arguments' => '',
+                            ],
+                        ]],
+                    ],
+                    'finish_reason' => null,
+                ]],
+            ]));
+            $fEmit($this->sse([
+                'id' => $sId,
+                'object' => 'chat.completion.chunk',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => [
+                        'tool_calls' => [[
+                            'index' => $iIndex,
+                            'function' => [
+                                'arguments' => $sArgs,
+                            ],
+                        ]],
+                    ],
+                    'finish_reason' => null,
+                ]],
+            ]));
+            $iIndex++;
+        }
+        $fEmit($this->sse([
+            'id' => $sId,
+            'object' => 'chat.completion.chunk',
+            'choices' => [[
+                'index' => 0,
+                'delta' => new stdClass(),
+                'finish_reason' => 'tool_calls',
+            ]],
+        ]));
+    }
+
+    protected function completionArray(string $sId, NeuronAI\Chat\Messages\Message $oMessage): array
+    {
+        $aMessage = [
+            'role' => 'assistant',
+            'content' => $oMessage->getContent(),
+        ];
+        $sFinish = 'stop';
+        if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage) {
+            $sFinish = 'tool_calls';
+            $aCalls = [];
+            foreach ($oMessage->getTools() as $oTool) {
+                $aInputs = $oTool->getInputs();
+                $aCalls[] = [
+                    'id' => (string)($oTool->getCallId() ?? ''),
+                    'type' => 'function',
+                    'function' => [
+                        'name' => $oTool->getName(),
+                        'arguments' => json_encode($aInputs === [] ? new stdClass() : $aInputs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+                    ],
+                ];
+            }
+            $aMessage['tool_calls'] = $aCalls;
+            if ($aMessage['content'] === null || $aMessage['content'] === '')
+                $aMessage['content'] = null;
+        }
+
+        return [
+            'id' => $sId,
+            'object' => 'chat.completion',
+            'choices' => [[
+                'index' => 0,
+                'message' => $aMessage,
+                'finish_reason' => $sFinish,
+            ]],
+        ];
+    }
+
+    protected function contentDelta(string $sId, string $sContent): string
+    {
+        return $this->sse([
+            'id' => $sId,
+            'object' => 'chat.completion.chunk',
+            'choices' => [[
+                'index' => 0,
+                'delta' => ['content' => $sContent],
+                'finish_reason' => null,
+            ]],
+        ]);
+    }
+
+    protected function sse(array $aPayload): string
+    {
+        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n\n";
+    }
+
+    protected function logProvider(Throwable $oException): void
+    {
+        bx_log('sys_agents', 'AI proxy provider failed: ' . $oException->getMessage(), BX_LOG_ERR);
+    }
+}
+
+/** @} */

+ 20 - 0
modules/boonex/ai_proxy/classes/BxAiProxyConfig.php

@@ -0,0 +1,20 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyConfig extends BxDolModuleConfig
+{
+    public function __construct($aModule)
+    {
+        parent::__construct($aModule);
+    }
+}
+
+/** @} */

+ 20 - 0
modules/boonex/ai_proxy/classes/BxAiProxyDb.php

@@ -0,0 +1,20 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyDb extends BxDolModuleDb
+{
+    public function __construct(&$oConfig)
+    {
+        parent::__construct($oConfig);
+    }
+}
+
+/** @} */

+ 158 - 0
modules/boonex/ai_proxy/classes/BxAiProxyModule.php

@@ -0,0 +1,158 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyModule extends BxDolModule
+{
+    function __construct($aModule)
+    {
+        parent::__construct($aModule);
+    }
+
+    public function serviceGetModelOptions()
+    {
+        $aResult = [
+            ['key' => '', 'value' => _t('_Select_one')],
+        ];
+
+        $aModels = (new BxDolAiQuery())->getModelsBy([
+            'sample' => 'all_pairs',
+            'active' => 1,
+            'capabilities' => ['chatvlm', 'chatllm'],
+            'type_not' => 'una-proxy',
+        ]);
+        if (!is_array($aModels))
+            return $aResult;
+
+        foreach ($aModels as $iId => $sTitle)
+            $aResult[] = ['key' => (int)$iId, 'value' => (string)$sTitle];
+
+        return $aResult;
+    }
+
+    public function completions(): void
+    {
+        bx_import('Auth', $this->_aModule);
+        bx_import('Completion', $this->_aModule);
+
+        $sBody = file_get_contents('php://input');
+        if ($sBody === false)
+            $sBody = '';
+
+        $aAuth = (new BxAiProxyAuth())->authenticateRequest(
+            $this->requestHeader('X-Una-Key'),
+            $this->requestHeader('X-Una-Sign'),
+            $sBody
+        );
+        if (empty($aAuth['ok']))
+            $this->jsonError(401, 'Unauthorized', 'authentication_error');
+
+        $aBody = json_decode($sBody, true);
+        if (!is_array($aBody))
+            $this->jsonError(400, 'Bad request', 'invalid_request_error');
+
+        $bStarted = false;
+        try {
+            $iId = (int)getParam('bx_ai_proxy_model');
+            $this->assertChatModel($iId);
+            $fFactory = static function (int $iModelId) {
+                return BxDolAIModelFactory::getModelInstance($iModelId);
+            };
+            $oCompletion = new BxAiProxyCompletion();
+            if (!empty($aBody['stream'])) {
+                $oCompletion->run($aBody, $iId, $fFactory, function (string $sEvent) use (&$bStarted) {
+                    if (!$bStarted) {
+                        header('Content-Type: text/event-stream; charset=utf-8');
+                        header('Cache-Control: no-cache');
+                        header('X-Accel-Buffering: no');
+                        $bStarted = true;
+                    }
+                    echo $sEvent;
+                    if (ob_get_level())
+                        ob_flush();
+                    flush();
+                });
+                return;
+            }
+
+            $sJson = $oCompletion->run($aBody, $iId, $fFactory);
+            header('Content-Type: application/json; charset=utf-8');
+            echo (string)$sJson;
+        }
+        catch (BxAiProxyException $oException) {
+            if ($bStarted || headers_sent())
+                return;
+            $sType = 'server_error';
+            if ($oException->httpCode() === 400)
+                $sType = 'invalid_request_error';
+            elseif ($oException->httpCode() === 401)
+                $sType = 'authentication_error';
+            $this->jsonError($oException->httpCode(), $oException->getMessage(), $sType);
+        }
+        catch (Throwable $oException) {
+            bx_log('sys_agents', 'AI proxy failed: ' . $oException->getMessage(), BX_LOG_ERR);
+            if ($bStarted || headers_sent())
+                return;
+            $this->jsonError(503, 'The proxy model is not available', 'server_error');
+        }
+    }
+
+    public function requestHeader(string $sName): string
+    {
+        $sServer = 'HTTP_' . strtoupper(str_replace('-', '_', $sName));
+        foreach ([$sServer, 'REDIRECT_' . $sServer] as $sKey) {
+            if (isset($_SERVER[$sKey]) && (string)$_SERVER[$sKey] !== '')
+                return trim((string)$_SERVER[$sKey]);
+        }
+
+        if (function_exists('getallheaders')) {
+            $aHeaders = getallheaders();
+            if (is_array($aHeaders)) {
+                foreach ($aHeaders as $sHeader => $sValue) {
+                    if (strcasecmp((string)$sHeader, $sName) === 0)
+                        return trim((string)$sValue);
+                }
+            }
+        }
+
+        return '';
+    }
+
+    protected function assertChatModel(int $iId): void
+    {
+        bx_import('Completion', $this->_aModule);
+
+        if ($iId <= 0)
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+
+        $aModel = BxDolAiQuery::getModelObject($iId);
+        if (!is_array($aModel) || empty($aModel['active']))
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+        if (!in_array((string)($aModel['capabilities'] ?? ''), ['chatllm', 'chatvlm'], true))
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+        if ((string)($aModel['type'] ?? '') === 'una-proxy')
+            throw new BxAiProxyException(503, 'The proxy model is not available');
+    }
+
+    protected function jsonError(int $iCode, string $sMessage, string $sType): void
+    {
+        http_response_code($iCode);
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode([
+            'error' => [
+                'message' => $sMessage,
+                'type' => $sType,
+            ],
+        ], JSON_UNESCAPED_UNICODE);
+        exit;
+    }
+}
+
+/** @} */

+ 73 - 0
modules/boonex/ai_proxy/install/config.php

@@ -0,0 +1,73 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_ai_proxy',
+    'title' => 'AI Proxy',
+    'note' => 'Operator chat proxy. Installed on the master site only.',
+    'version' => '15.0.0.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/ai_proxy/',
+    'home_uri' => 'ai_proxy',
+
+    'db_prefix' => 'bx_ai_proxy_',
+    'class_prefix' => 'BxAiProxy',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'AI Proxy',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(
+        'oauth2' => 'BoonEx OAuth2 Server Module'
+    ),
+
+);
+
+/** @} */

+ 20 - 0
modules/boonex/ai_proxy/install/installer.php

@@ -0,0 +1,20 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyInstaller extends BxDolStudioInstaller
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+}
+
+/** @} */

+ 6 - 0
modules/boonex/ai_proxy/install/langs/en.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources name="en" flag="gb" title="English">
+    <string name="_bx_ai_proxy"><![CDATA[AI Proxy]]></string>
+    <string name="_bx_ai_proxy_option_model"><![CDATA[Chat model]]></string>
+    <string name="_bx_ai_proxy_option_model_info"><![CDATA[Active chat model that answers operator proxy requests. The client cannot choose a different model.]]></string>
+</resources>

+ 6 - 0
modules/boonex/ai_proxy/install/langs/ru.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources name="ru" flag="ru" title="Russian">
+    <string name="_bx_ai_proxy"><![CDATA[Прокси ИИ]]></string>
+    <string name="_bx_ai_proxy_option_model"><![CDATA[Модель чата]]></string>
+    <string name="_bx_ai_proxy_option_model_info"><![CDATA[Активная модель чата для запросов прокси оператора. Сайт клиента не может выбрать другую модель.]]></string>
+</resources>

+ 4 - 0
modules/boonex/ai_proxy/install/sql/disable.sql

@@ -0,0 +1,4 @@
+SET @sName = 'bx_ai_proxy';
+
+-- SETTINGS
+DELETE FROM `tot`, `toc`, `to` USING `sys_options_types` AS `tot` LEFT JOIN `sys_options_categories` AS `toc` ON `tot`.`id`=`toc`.`type_id` LEFT JOIN `sys_options` AS `to` ON `toc`.`id`=`to`.`category_id` WHERE `tot`.`name`=@sName;

+ 14 - 0
modules/boonex/ai_proxy/install/sql/enable.sql

@@ -0,0 +1,14 @@
+SET @sName = 'bx_ai_proxy';
+
+-- SETTINGS
+SET @iTypeOrder = (SELECT MAX(`order`) FROM `sys_options_types` WHERE `group` = 'modules');
+INSERT INTO `sys_options_types`(`group`, `name`, `caption`, `icon`, `order`) VALUES
+('modules', @sName, '_bx_ai_proxy', 'bx_ai_proxy@modules/boonex/ai_proxy/|std-icon.svg', IF(ISNULL(@iTypeOrder), 1, @iTypeOrder + 1));
+SET @iTypeId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `order`)
+VALUES (@iTypeId, @sName, '_bx_ai_proxy', 10);
+SET @iCategId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`name`, `value`, `category_id`, `caption`, `info`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+('bx_ai_proxy_model', '', @iCategId, '_bx_ai_proxy_option_model', '_bx_ai_proxy_option_model_info', 'select', '{"module":"bx_ai_proxy","method":"get_model_options"}', '', '', '', 1);

+ 14 - 0
modules/boonex/ai_proxy/install/sql/install.sql

@@ -0,0 +1,14 @@
+-- AI Proxy has no tables. The chat model setting is added on enable.
+
+-- Studio page and widget
+
+INSERT INTO `sys_std_pages`(`index`, `name`, `header`, `caption`, `icon`) VALUES
+(3, 'bx_ai_proxy', '_bx_ai_proxy', '_bx_ai_proxy', 'bx_ai_proxy@modules/boonex/ai_proxy/|std-icon.svg');
+SET @iPageId = LAST_INSERT_ID();
+
+SET @iParentPageId = (SELECT `id` FROM `sys_std_pages` WHERE `name` = 'home');
+SET @iParentPageOrder = (SELECT MAX(`order`) FROM `sys_std_pages_widgets` WHERE `page_id` = @iParentPageId);
+INSERT INTO `sys_std_widgets` (`page_id`, `module`, `type`, `url`, `click`, `icon`, `caption`, `cnt_notices`, `cnt_actions`) VALUES
+(@iPageId, 'bx_ai_proxy', 'integrations', '{url_studio}module.php?name=bx_ai_proxy', '', 'bx_ai_proxy@modules/boonex/ai_proxy/|std-icon.svg', '_bx_ai_proxy', '', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:11:"get_actions";s:6:"params";a:0:{}s:5:"class";s:18:"TemplStudioModules";}');
+INSERT INTO `sys_std_pages_widgets` (`page_id`, `widget_id`, `order`) VALUES
+(@iParentPageId, LAST_INSERT_ID(), IF(ISNULL(@iParentPageOrder), 1, @iParentPageOrder + 1));

+ 6 - 0
modules/boonex/ai_proxy/install/sql/uninstall.sql

@@ -0,0 +1,6 @@
+-- Settings are removed by disable.sql, which uninstall runs first.
+
+-- Studio page and widget
+DELETE FROM `tp`, `tw`, `twb`, `tpw`
+USING `sys_std_pages` AS `tp` LEFT JOIN `sys_std_widgets` AS `tw` ON `tp`.`id` = `tw`.`page_id` LEFT JOIN `sys_std_widgets_bookmarks` AS `twb` ON `tw`.`id` = `twb`.`widget_id` LEFT JOIN `sys_std_pages_widgets` AS `tpw` ON `tw`.`id` = `tpw`.`widget_id`
+WHERE `tp`.`name` = 'bx_ai_proxy';

+ 38 - 0
modules/boonex/ai_proxy/request.php

@@ -0,0 +1,38 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aRequest = array_values(array_filter($GLOBALS['aRequest'] ?? [], static function ($sPart) {
+    return $sPart !== '';
+}));
+
+$bCompletion = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'
+    && $aRequest === ['v1', 'chat', 'completions']
+    && !empty($GLOBALS['aModule']['enabled']);
+
+if (!$bCompletion) {
+    http_response_code(404);
+    header('Content-Type: application/json; charset=utf-8');
+    echo json_encode([
+        'error' => [
+            'message' => 'Not found',
+            'type' => 'not_found',
+        ],
+    ]);
+    return;
+}
+
+$oModule = BxDolModule::getInstance($GLOBALS['aModule']['name']);
+if (!$oModule)
+    return;
+
+$oModule->completions();
+
+/** @} */

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
modules/boonex/ai_proxy/template/images/icons/std-icon.svg


+ 3 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -2491,6 +2491,7 @@
     <string name="_sys_agents_field_prompt_tools"><![CDATA[Tools Prompt]]></string>
     <string name="_sys_agents_field_prompt_tools_info"><![CDATA[Instructions for using available tools.]]></string>
     <string name="_sys_agents_tools_popup_edit"><![CDATA[Edit tool]]></string>
+    <string name="_sys_agents_una_proxy_unconfigured"><![CDATA[The UNA proxy is not configured. Set the site UNA key and secret in Studio.]]></string>
     <string name="_sys_agents_field_title"><![CDATA[Title]]></string>
     <string name="_sys_agents_field_title_info"><![CDATA[Title to display]]></string>
     <string name="_sys_agents_field_description"><![CDATA[Description]]></string>
@@ -3878,7 +3879,9 @@
     <string name="_sys_page_block_title_sys_author"><![CDATA[Author]]></string>
     <string name="_sys_page_block_title_author"><![CDATA[Author]]></string>
     <string name="_sys_page_block_title_sys_ai_agent"><![CDATA[AI Agent]]></string>
+    <string name="_sys_page_block_title_sys_ai_agent_operator"><![CDATA[AI Agent Operator (for App only)]]></string>
     <string name="_sys_page_block_title_ai_agent"><![CDATA[AI Agent]]></string>
+    <string name="_sys_page_block_title_ai_agent_operator"><![CDATA[Agent]]></string>
     <string name="_sys_page_block_title_con_friends"><![CDATA[Friends]]></string>
     <string name="_sys_page_block_title_con_friend_requests"><![CDATA[Friend Requests]]></string>
     <string name="_sys_page_block_title_con_friend_requested"><![CDATA[Sent Friend Requests]]></string>

+ 3 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -2461,6 +2461,7 @@
         <string name="_sys_agents_field_prompt_tools"><![CDATA[Инструкция по инструментам]]></string>
         <string name="_sys_agents_field_prompt_tools_info"><![CDATA[Инструкции по использованию доступных инструментов.]]></string>
         <string name="_sys_agents_tools_popup_edit"><![CDATA[Редактирование инструмента]]></string>
+        <string name="_sys_agents_una_proxy_unconfigured"><![CDATA[Прокси UNA не настроен. Укажите ключ и секрет сайта в Studio.]]></string>
         <string name="_sys_agents_field_title"><![CDATA[Название]]></string>
         <string name="_sys_agents_field_title_info"><![CDATA[Название для отображения]]></string>
         <string name="_sys_agents_field_description"><![CDATA[Описание]]></string>
@@ -3835,7 +3836,9 @@
         <string name="_sys_page_block_title_sys_author"><![CDATA[Автор]]></string>
 	<string name="_sys_page_block_title_author"><![CDATA[Автор]]></string>
         <string name="_sys_page_block_title_sys_ai_agent"><![CDATA[ИИ агент]]></string>
+        <string name="_sys_page_block_title_sys_ai_agent_operator"><![CDATA[ИИ агент оператора (только для App-а)]]></string>
         <string name="_sys_page_block_title_ai_agent"><![CDATA[ИИ агент]]></string>
+        <string name="_sys_page_block_title_ai_agent_operator"><![CDATA[Агент]]></string>
         <string name="_sys_page_block_title_con_friends"><![CDATA[Друзья]]></string>
         <string name="_sys_page_block_title_con_friend_requests"><![CDATA[Хотят дружить]]></string>
         <string name="_sys_page_block_title_con_friend_requested"><![CDATA[Запрошенная дружба]]></string>

+ 2 - 0
scripts/studio_icon_from_lucide.py

@@ -26,6 +26,7 @@ PLATES = {
     'purple': ('#7C3AED', '#5B21B6'),
     'red':    ('#F43F5E', '#BE123C'),
     'white':  ('#F3F4F6', '#E5E7EB'),
+    'rose':   ('#984F65', '#691C35'),
 }
 # glyph gradient (top, bottom): white fading out on a coloured plate, zinc on the white plate
 GLYPH = {
@@ -65,6 +66,7 @@ ICONS = [
     ('modules/boonex/nexus/template/images/icons/std-icon.svg', 'nexus', 'gray', 'galaxy', 80),
     ('modules/boonex/stream/template/images/icons/std-icon.svg', 'stream', 'green', 'circle-play', 80),
     ('modules/boonex/resources/template/images/icons/std-icon.svg', 'resources', 'green', 'rows-3', 80),
+    ('modules/boonex/ai_proxy/template/images/icons/std-icon.svg', 'ai_proxy', 'rose', 'bot', 80),
 ]
 
 # Brand artwork on the white plate: the source SVG's shapes are copied as they are (fills, gradients), scaled to

+ 174 - 0
tests/units/inc/classes/BxDolAIProviderUnaProxyTest.php

@@ -0,0 +1,174 @@
+<?php
+
+/**
+ * UNA proxy model: HMAC headers, and OpenAI tool_calls chunks parsed as a Neuron tool message.
+ */
+class BxDolAIProviderUnaProxyTest extends \PHPUnit\Framework\TestCase
+{
+    public function testMatchingKeyAndSecret()
+    {
+        $sBody = '{"model":"una-proxy","messages":[{"role":"user","content":"hi"}]}';
+        $sSecret = 'right-secret';
+        $oClient = new BxDolAIUnaProxyHttpClient('site-key', $sSecret);
+        $aHeaders = $oClient->headersForBody($sBody);
+
+        $this->assertSame('site-key', $aHeaders['X-Una-Key']);
+        $this->assertSame(hash_hmac('sha256', $sBody, $sSecret), $aHeaders['X-Una-Sign']);
+        $this->assertArrayNotHasKey('Authorization', $aHeaders);
+        $this->assertNotContains($sSecret, $aHeaders);
+    }
+
+    public function testWrongSecretDoesNotMatch()
+    {
+        $sBody = '{"model":"una-proxy","messages":[{"role":"user","content":"hi"}]}';
+        $oClient = new BxDolAIUnaProxyHttpClient('site-key', 'wrong-secret');
+        $aHeaders = $oClient->headersForBody($sBody);
+
+        $this->assertNotSame(hash_hmac('sha256', $sBody, 'right-secret'), $aHeaders['X-Una-Sign']);
+        $this->assertArrayNotHasKey('Authorization', $aHeaders);
+    }
+
+    public function testEmptyKeyDoesNotBuildRequest()
+    {
+        $oHttp = $this->createMock(NeuronAI\HttpClient\HttpClientInterface::class);
+        $oHttp->expects($this->never())->method('request');
+        $oHttp->expects($this->never())->method('stream');
+        $oHttp->expects($this->never())->method('withBaseUri');
+        $oHttp->expects($this->never())->method('withHeaders');
+
+        $this->expectException(Exception::class);
+        new BxDolAIProviderUnaProxy('', 'secret', 'una-proxy', $oHttp);
+    }
+
+    public function testToolCallChunkBecomesNeuronToolMessage()
+    {
+        $aLines = [
+            'data: ' . json_encode([
+                'id' => 'chatcmpl-1',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => [
+                        'tool_calls' => [[
+                            'index' => 0,
+                            'id' => 'call_1',
+                            'type' => 'function',
+                            'function' => [
+                                'name' => 'content_search',
+                                'arguments' => '',
+                            ],
+                        ]],
+                    ],
+                ]],
+            ]) . "\n",
+            'data: ' . json_encode([
+                'id' => 'chatcmpl-1',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => [
+                        'tool_calls' => [[
+                            'index' => 0,
+                            'function' => [
+                                'arguments' => '{"keyword":"cats"}',
+                            ],
+                        ]],
+                    ],
+                ]],
+            ]) . "\n",
+            'data: ' . json_encode([
+                'id' => 'chatcmpl-1',
+                'choices' => [[
+                    'index' => 0,
+                    'delta' => new stdClass(),
+                    'finish_reason' => 'tool_calls',
+                ]],
+            ]) . "\n",
+            "data: [DONE]\n",
+        ];
+
+        $oProvider = new BxDolAIProviderUnaProxy(
+            'site-key',
+            'site-secret',
+            'una-proxy',
+            new BxDolAIProxyTestHttpClient(new BxDolAIProxyTestStream($aLines))
+        );
+        $oProvider->setTools([
+            NeuronAI\Tools\Tool::make('content_search', 'Search content'),
+        ]);
+
+        $oGen = $oProvider->stream(new NeuronAI\Chat\Messages\UserMessage('find cats'));
+        foreach ($oGen as $oChunk) {
+        }
+        $oMessage = $oGen->getReturn();
+
+        $this->assertInstanceOf(NeuronAI\Chat\Messages\ToolCallMessage::class, $oMessage);
+        $aTools = $oMessage->getTools();
+        $this->assertCount(1, $aTools);
+        $this->assertSame('content_search', $aTools[0]->getName());
+        $this->assertSame(['keyword' => 'cats'], $aTools[0]->getInputs());
+    }
+}
+
+class BxDolAIProxyTestStream implements NeuronAI\HttpClient\StreamInterface
+{
+    private int $iLine = 0;
+
+    public function __construct(private array $aLines)
+    {
+    }
+
+    public function eof(): bool
+    {
+        return $this->iLine >= count($this->aLines);
+    }
+
+    public function read(int $length): string
+    {
+        return '';
+    }
+
+    public function readLine(): string
+    {
+        if ($this->eof())
+            return '';
+
+        $sLine = $this->aLines[$this->iLine];
+        $this->iLine++;
+        return $sLine;
+    }
+
+    public function close(): void
+    {
+    }
+}
+
+class BxDolAIProxyTestHttpClient implements NeuronAI\HttpClient\HttpClientInterface
+{
+    public function __construct(private NeuronAI\HttpClient\StreamInterface $oStream)
+    {
+    }
+
+    public function request(NeuronAI\HttpClient\HttpRequest $request): NeuronAI\HttpClient\HttpResponse
+    {
+        throw new Exception('request');
+    }
+
+    public function stream(NeuronAI\HttpClient\HttpRequest $request): NeuronAI\HttpClient\StreamInterface
+    {
+        return $this->oStream;
+    }
+
+    public function withBaseUri(string $baseUri): NeuronAI\HttpClient\HttpClientInterface
+    {
+        return $this;
+    }
+
+    public function withHeaders(array $headers): NeuronAI\HttpClient\HttpClientInterface
+    {
+        return $this;
+    }
+
+    public function withTimeout(float $timeout): NeuronAI\HttpClient\HttpClientInterface
+    {
+        return $this;
+    }
+}

+ 61 - 0
tests/units/inc/classes/BxDolAiAgentRetrievalTest.php

@@ -0,0 +1,61 @@
+<?php
+
+/**
+ * The operator agent has no knowledge store, so chat must not read NeuronAI's embeddings provider.
+ */
+class BxDolAiAgentRetrievalTest extends \PHPUnit\Framework\TestCase
+{
+    public function testAgentWithoutStoreSkipsEmbeddings()
+    {
+        $o = new BxDolAiAgent(['id' => 1, 'vector_store_id' => 0]);
+        $oRetrieval = $o->resolveRetrieval();
+
+        $this->assertInstanceOf(BxDolAiRetrievalNone::class, $oRetrieval);
+        $this->assertSame([], $oRetrieval->retrieve(new NeuronAI\Chat\Messages\UserMessage('hello')));
+    }
+
+    public function testStoreWithoutEmbeddingModelIsRejected()
+    {
+        $o = new BxDolAiAgent(['id' => 1, 'vector_store_id' => 9]);
+
+        try {
+            $o->resolveRetrieval();
+            $this->fail('Retrieval must not run without an embedding model');
+        } catch (Exception $oException) {
+            $this->assertSame('Vector store 9 has no embedding model', $oException->getMessage());
+        }
+    }
+
+    public function testConfiguredEmbeddingModelUsesSimilarityRetrieval()
+    {
+        $o = new BxDolAiAgent(['id' => 1, 'vector_store_id' => 0]);
+        $o->setEmbeddingsProvider(new BxDolAiAgentRetrievalTestEmbeddings());
+
+        $this->assertInstanceOf(
+            NeuronAI\RAG\Retrieval\SimilarityRetrieval::class,
+            $o->resolveRetrieval()
+        );
+    }
+}
+
+class BxDolAiAgentRetrievalTestEmbeddings implements NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+{
+    public function embedText(string $text): array
+    {
+        return [0.0];
+    }
+
+    public function embedDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\Document
+    {
+        $document->embedding = [0.0];
+        return $document;
+    }
+
+    public function embedDocuments(array $documents): array
+    {
+        foreach ($documents as $document)
+            $this->embedDocument($document);
+
+        return $documents;
+    }
+}

+ 21 - 0
tests/units/inc/classes/BxDolAiTriggerTest.php

@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * Operators see the provider's own error. The log keeps the full HTTP text.
+ */
+class BxDolAiTriggerTest extends \PHPUnit\Framework\TestCase
+{
+    public function testAuthenticationErrorIsTheProviderMessage()
+    {
+        $o = new Exception('HTTP 401 error during POST chat/completions: {"error":{"message":"Unauthorized","type":"authentication_error"}}');
+
+        $this->assertSame('Unauthorized', BxDolAiTrigger::visibleError($o));
+    }
+
+    public function testPlainExceptionStaysIntact()
+    {
+        $o = new Exception('Vector store 9 has no embedding model');
+
+        $this->assertSame('Vector store 9 has no embedding model', BxDolAiTrigger::visibleError($o));
+    }
+}

+ 219 - 0
tests/units/modules/boonex/ai_proxy/classes/BxAiProxyTest.php

@@ -0,0 +1,219 @@
+<?php
+
+/**
+ * Master proxy auth and model selection. Loaded without enabling bx_ai_proxy.
+ */
+class BxAiProxyTest extends \PHPUnit\Framework\TestCase
+{
+    public static function setUpBeforeClass(): void
+    {
+        require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyAuth.php';
+        require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyCompletion.php';
+    }
+
+    public function testMatchingKeyAndSecret()
+    {
+        $sBody = '{"model":"gpt-not-the-studio-model"}';
+        $sSecret = 'client-secret';
+        $sSign = hash_hmac('sha256', $sBody, $sSecret);
+        $bLookup = false;
+
+        $aResult = (new BxAiProxyAuth())->authenticate('site-key', $sSign, $sBody, function (string $sKey) use (&$bLookup, $sSecret) {
+            $bLookup = true;
+            $this->assertSame('site-key', $sKey);
+            return ['client_id' => $sKey, 'client_secret' => $sSecret];
+        });
+
+        $this->assertTrue($bLookup);
+        $this->assertTrue($aResult['ok']);
+        $this->assertSame(200, $aResult['code']);
+    }
+
+    public function testWrongSecretIsUnauthorized()
+    {
+        $sBody = '{"model":"gpt-not-the-studio-model"}';
+        $aResult = (new BxAiProxyAuth())->authenticate('site-key', hash_hmac('sha256', $sBody, 'other-secret'), $sBody, function () {
+            return ['client_id' => 'site-key', 'client_secret' => 'client-secret'];
+        });
+
+        $this->assertFalse($aResult['ok']);
+        $this->assertSame(401, $aResult['code']);
+    }
+
+    public function testEmptyKeyDoesNotLookUp()
+    {
+        $bLookup = false;
+        $aResult = (new BxAiProxyAuth())->authenticate('', 'sign', '{}', function () use (&$bLookup) {
+            $bLookup = true;
+            return ['client_secret' => 'client-secret'];
+        });
+
+        $this->assertFalse($bLookup);
+        $this->assertFalse($aResult['ok']);
+        $this->assertSame(401, $aResult['code']);
+    }
+
+    public function testRequestModelDoesNotSelectTheProvider()
+    {
+        $sBodyModel = 'gpt-not-the-studio-model';
+        $iSeen = 0;
+        $oProvider = new BxAiProxyTestProvider();
+
+        $sJson = (new BxAiProxyCompletion())->run([
+            'model' => $sBodyModel,
+            'temperature' => 0.2,
+            'max_tokens' => 16,
+            'top_p' => 0.5,
+            'messages' => [
+                ['role' => 'user', 'content' => 'hello'],
+            ],
+            'stream' => false,
+        ], 42, function (int $iId) use (&$iSeen, $oProvider) {
+            $iSeen = $iId;
+            return $oProvider;
+        });
+
+        $this->assertSame(42, $iSeen);
+        $this->assertFalse($oProvider->saw($sBodyModel));
+        $this->assertIsString($sJson);
+        $this->assertStringNotContainsString($sBodyModel, $sJson);
+    }
+
+    public function testNestedToolSchemaKeepsArrayItemsAndObjectProperties()
+    {
+        $oProvider = new BxAiProxyTestProvider();
+        (new BxAiProxyCompletion())->run([
+            'messages' => [
+                ['role' => 'user', 'content' => 'search'],
+            ],
+            'tools' => [[
+                'type' => 'function',
+                'function' => [
+                    'name' => 'content_search',
+                    'description' => 'Search content',
+                    'parameters' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'keyword' => [
+                                'type' => 'string',
+                                'description' => 'The keyword to search for.',
+                            ],
+                            'sections' => [
+                                'type' => 'array',
+                                'description' => 'List of sections to search in.',
+                                'items' => [
+                                    'type' => 'string',
+                                    'description' => 'Section name.',
+                                ],
+                            ],
+                            'data' => [
+                                'type' => 'array',
+                                'description' => 'Fields to write.',
+                                'minItems' => 1,
+                                'maxItems' => 8,
+                                'items' => [
+                                    'type' => 'object',
+                                    'properties' => [
+                                        'name' => ['type' => 'string', 'description' => 'Parameter name'],
+                                        'value' => ['type' => 'string', 'description' => 'Parameter value'],
+                                    ],
+                                    'required' => ['name', 'value'],
+                                ],
+                            ],
+                        ],
+                        'required' => ['keyword', 'data'],
+                    ],
+                ],
+            ]],
+        ], 7, function () use ($oProvider) {
+            return $oProvider;
+        });
+
+        $this->assertCount(1, $oProvider->aTools);
+        $aMapped = (new NeuronAI\Providers\OpenAI\ToolMapper())->map($oProvider->aTools);
+        $aProperties = $aMapped[0]['function']['parameters']['properties'];
+
+        $this->assertSame(['keyword', 'data'], $aMapped[0]['function']['parameters']['required']);
+        $this->assertSame('string', $aProperties['sections']['items']['type']);
+        $this->assertSame('Section name.', $aProperties['sections']['items']['description']);
+        $this->assertSame('object', $aProperties['data']['items']['type']);
+        $this->assertSame('string', $aProperties['data']['items']['properties']['name']['type']);
+        $this->assertSame(['name', 'value'], $aProperties['data']['items']['required']);
+        $this->assertSame(1, $aProperties['data']['minItems']);
+        $this->assertSame(8, $aProperties['data']['maxItems']);
+    }
+}
+
+class BxAiProxyTestProvider implements NeuronAI\Providers\AIProviderInterface
+{
+    public string $sSystem = '';
+
+    /** @var list<NeuronAI\Chat\Messages\Message> */
+    public array $aMessages = [];
+
+    /** @var list<NeuronAI\Tools\ToolInterface> */
+    public array $aTools = [];
+
+    public function systemPrompt(?string $prompt): NeuronAI\Providers\AIProviderInterface
+    {
+        $this->sSystem = (string)$prompt;
+        return $this;
+    }
+
+    public function setTools(array $tools): NeuronAI\Providers\AIProviderInterface
+    {
+        $this->aTools = $tools;
+        return $this;
+    }
+
+    public function messageMapper(): NeuronAI\Providers\MessageMapperInterface
+    {
+        throw new Exception('unused');
+    }
+
+    public function toolPayloadMapper(): NeuronAI\Providers\ToolMapperInterface
+    {
+        throw new Exception('unused');
+    }
+
+    public function chat(NeuronAI\Chat\Messages\Message ...$messages): NeuronAI\Chat\Messages\Message
+    {
+        $this->aMessages = $messages;
+        return new NeuronAI\Chat\Messages\AssistantMessage('ok');
+    }
+
+    public function stream(NeuronAI\Chat\Messages\Message ...$messages): Generator
+    {
+        $this->aMessages = $messages;
+        yield new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('m', 'ok');
+        return new NeuronAI\Chat\Messages\AssistantMessage('ok');
+    }
+
+    public function structured(array|NeuronAI\Chat\Messages\Message $messages, string $class, array $response_schema): NeuronAI\Chat\Messages\Message
+    {
+        throw new Exception('unused');
+    }
+
+    public function setHttpClient(NeuronAI\HttpClient\HttpClientInterface $client): NeuronAI\Providers\AIProviderInterface
+    {
+        return $this;
+    }
+
+    public function saw(string $sNeedle): bool
+    {
+        if (str_contains($this->sSystem, $sNeedle))
+            return true;
+
+        foreach ($this->aMessages as $oMessage) {
+            if (str_contains((string)$oMessage->getContent(), $sNeedle))
+                return true;
+        }
+
+        foreach ($this->aTools as $oTool) {
+            if (str_contains($oTool->getName(), $sNeedle) || str_contains((string)$oTool->getDescription(), $sNeedle))
+                return true;
+        }
+
+        return false;
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов