BxAiProxyCompletion.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup AIProxy AI Proxy
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. class BxAiProxyException extends Exception
  12. {
  13. public function __construct(protected int $iHttpCode, string $sMessage)
  14. {
  15. parent::__construct($sMessage);
  16. }
  17. public function httpCode(): int
  18. {
  19. return $this->iHttpCode;
  20. }
  21. }
  22. class BxAiProxyCompletion
  23. {
  24. /**
  25. * Studio model id for this turn. The request body model is ignored.
  26. */
  27. public function providerId(array $aBody, int $iConfiguredId): int
  28. {
  29. if ($iConfiguredId <= 0)
  30. throw new BxAiProxyException(503, 'The proxy model is not available');
  31. return $iConfiguredId;
  32. }
  33. /**
  34. * @param callable(int): NeuronAI\Providers\AIProviderInterface $fFactory
  35. * @param callable(string): void|null $fEmit
  36. */
  37. public function run(array $aBody, int $iConfiguredId, callable $fFactory, ?callable $fEmit = null): ?string
  38. {
  39. [$oProvider, $aMessages] = $this->prepare($aBody, $iConfiguredId, $fFactory);
  40. $sId = 'chatcmpl-' . bin2hex(random_bytes(8));
  41. if (!empty($aBody['stream'])) {
  42. $this->streamTo($oProvider, $aMessages, $sId, $fEmit ?? static function (string $sEvent) {
  43. });
  44. return null;
  45. }
  46. try {
  47. $oMessage = $oProvider->chat(...$aMessages);
  48. }
  49. catch (BxAiProxyException $oException) {
  50. throw $oException;
  51. }
  52. catch (Throwable $oException) {
  53. $this->logProvider($oException);
  54. throw new BxAiProxyException(503, 'The proxy model is not available');
  55. }
  56. if (!$oMessage instanceof NeuronAI\Chat\Messages\Message)
  57. throw new BxAiProxyException(503, 'The proxy model is not available');
  58. return json_encode($this->completionArray($sId, $oMessage), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
  59. }
  60. /**
  61. * @param callable(int): NeuronAI\Providers\AIProviderInterface $fFactory
  62. * @return array{0: NeuronAI\Providers\AIProviderInterface, 1: list<NeuronAI\Chat\Messages\Message>}
  63. */
  64. protected function prepare(array $aBody, int $iConfiguredId, callable $fFactory): array
  65. {
  66. $iId = $this->providerId($aBody, $iConfiguredId);
  67. try {
  68. $oProvider = $fFactory($iId);
  69. }
  70. catch (BxAiProxyException $oException) {
  71. throw $oException;
  72. }
  73. catch (Throwable $oException) {
  74. $this->logProvider($oException);
  75. throw new BxAiProxyException(503, 'The proxy model is not available');
  76. }
  77. if (!$oProvider instanceof NeuronAI\Providers\AIProviderInterface)
  78. throw new BxAiProxyException(503, 'The proxy model is not available');
  79. $aMapped = $this->mapBody($aBody);
  80. if ($aMapped['system'] !== '')
  81. $oProvider->systemPrompt($aMapped['system']);
  82. if ($aMapped['tools'] !== [])
  83. $oProvider->setTools($aMapped['tools']);
  84. if ($aMapped['messages'] === [])
  85. throw new BxAiProxyException(400, 'Bad request');
  86. return [$oProvider, $aMapped['messages']];
  87. }
  88. /**
  89. * @return array{system: string, messages: list<NeuronAI\Chat\Messages\Message>, tools: list<NeuronAI\Tools\Tool>}
  90. */
  91. protected function mapBody(array $aBody): array
  92. {
  93. if (!isset($aBody['messages']) || !is_array($aBody['messages']))
  94. throw new BxAiProxyException(400, 'Bad request');
  95. $aSystem = [];
  96. $aMessages = [];
  97. $aCallNames = [];
  98. foreach ($aBody['messages'] as $aMessage) {
  99. if (!is_array($aMessage))
  100. continue;
  101. $sRole = (string)($aMessage['role'] ?? '');
  102. if ($sRole === 'system' || $sRole === 'developer') {
  103. $sText = $this->textOf($aMessage['content'] ?? '');
  104. if ($sText !== '')
  105. $aSystem[] = $sText;
  106. continue;
  107. }
  108. if ($sRole === 'tool') {
  109. $sCallId = (string)($aMessage['tool_call_id'] ?? '');
  110. $sName = (string)($aMessage['name'] ?? ($aCallNames[$sCallId] ?? 'tool'));
  111. if ($sName === '')
  112. $sName = 'tool';
  113. $oTool = NeuronAI\Tools\Tool::make($sName, '');
  114. if ($sCallId !== '')
  115. $oTool->setCallId($sCallId);
  116. $oTool->setResult($this->textOf($aMessage['content'] ?? ''));
  117. $aMessages[] = new NeuronAI\Chat\Messages\ToolResultMessage([$oTool]);
  118. continue;
  119. }
  120. if ($sRole === 'assistant' && !empty($aMessage['tool_calls']) && is_array($aMessage['tool_calls'])) {
  121. $aCalls = [];
  122. foreach ($aMessage['tool_calls'] as $aCall) {
  123. if (!is_array($aCall))
  124. continue;
  125. $oTool = $this->callTool($aCall);
  126. $sCallId = (string)($oTool->getCallId() ?? '');
  127. if ($sCallId !== '')
  128. $aCallNames[$sCallId] = $oTool->getName();
  129. $aCalls[] = $oTool;
  130. }
  131. if ($aCalls === [])
  132. continue;
  133. $aMessages[] = new NeuronAI\Chat\Messages\ToolCallMessage($this->contentBlocks($aMessage['content'] ?? null), $aCalls);
  134. continue;
  135. }
  136. if ($sRole === 'assistant') {
  137. $aMessages[] = new NeuronAI\Chat\Messages\AssistantMessage($this->contentBlocks($aMessage['content'] ?? ''));
  138. continue;
  139. }
  140. if ($sRole === 'user')
  141. $aMessages[] = new NeuronAI\Chat\Messages\UserMessage($this->contentBlocks($aMessage['content'] ?? ''));
  142. }
  143. return [
  144. 'system' => implode("\n\n", $aSystem),
  145. 'messages' => $aMessages,
  146. 'tools' => $this->definedTools($aBody),
  147. ];
  148. }
  149. /**
  150. * @return list<NeuronAI\Tools\Tool>
  151. */
  152. protected function definedTools(array $aBody): array
  153. {
  154. if (empty($aBody['tools']) || !is_array($aBody['tools']))
  155. return [];
  156. $aTools = [];
  157. foreach ($aBody['tools'] as $aTool) {
  158. if (!is_array($aTool))
  159. continue;
  160. $aFunction = isset($aTool['function']) && is_array($aTool['function']) ? $aTool['function'] : $aTool;
  161. $sName = (string)($aFunction['name'] ?? '');
  162. if ($sName === '')
  163. continue;
  164. $aParameters = isset($aFunction['parameters']) && is_array($aFunction['parameters']) ? $aFunction['parameters'] : [];
  165. $aTools[] = $this->stubTool($sName, (string)($aFunction['description'] ?? ''), $aParameters);
  166. }
  167. return $aTools;
  168. }
  169. protected function stubTool(string $sName, string $sDescription, array $aParameters): NeuronAI\Tools\Tool
  170. {
  171. $oTool = NeuronAI\Tools\Tool::make($sName, $sDescription);
  172. foreach ($this->schemaProperties($aParameters) as $oProperty)
  173. $oTool->addProperty($oProperty);
  174. return $oTool;
  175. }
  176. /**
  177. * @return list<NeuronAI\Tools\ToolPropertyInterface>
  178. */
  179. protected function schemaProperties(array $aSchema): array
  180. {
  181. $aProperties = isset($aSchema['properties']) && is_array($aSchema['properties']) ? $aSchema['properties'] : [];
  182. $aRequired = isset($aSchema['required']) && is_array($aSchema['required']) ? $aSchema['required'] : [];
  183. $aBuilt = [];
  184. foreach ($aProperties as $sProp => $aProperty) {
  185. if (!is_string($sProp) || !is_array($aProperty))
  186. continue;
  187. $aBuilt[] = $this->schemaProperty($sProp, $aProperty, in_array($sProp, $aRequired, true));
  188. }
  189. return $aBuilt;
  190. }
  191. protected function schemaProperty(string $sName, array $aSchema, bool $bRequired): NeuronAI\Tools\ToolPropertyInterface
  192. {
  193. $sType = $aSchema['type'] ?? 'string';
  194. try {
  195. $oType = NeuronAI\Tools\PropertyType::fromSchema(is_array($sType) ? $sType : (string)$sType);
  196. }
  197. catch (Throwable $oException) {
  198. $oType = NeuronAI\Tools\PropertyType::STRING;
  199. }
  200. $sDescription = isset($aSchema['description']) ? (string)$aSchema['description'] : null;
  201. if ($oType === NeuronAI\Tools\PropertyType::ARRAY)
  202. return $this->arrayProperty($sName, $aSchema, $bRequired, $sDescription);
  203. if ($oType === NeuronAI\Tools\PropertyType::OBJECT)
  204. return $this->objectProperty($sName, $aSchema, $bRequired, $sDescription);
  205. $aEnum = isset($aSchema['enum']) && is_array($aSchema['enum']) ? array_values($aSchema['enum']) : [];
  206. return new NeuronAI\Tools\ToolProperty($sName, $oType, $sDescription, $bRequired, $aEnum);
  207. }
  208. protected function arrayProperty(string $sName, array $aSchema, bool $bRequired, ?string $sDescription): NeuronAI\Tools\ArrayProperty
  209. {
  210. $oItems = null;
  211. if (isset($aSchema['items']) && is_array($aSchema['items']) && !array_is_list($aSchema['items']))
  212. $oItems = $this->schemaProperty($sName . '_item', $aSchema['items'], false);
  213. $iMin = isset($aSchema['minItems']) && is_int($aSchema['minItems']) && $aSchema['minItems'] >= 0 ? $aSchema['minItems'] : null;
  214. $iMax = isset($aSchema['maxItems']) && is_int($aSchema['maxItems']) && $aSchema['maxItems'] >= 0 ? $aSchema['maxItems'] : null;
  215. if ($iMin !== null && $iMax !== null && $iMin > $iMax) {
  216. $iMin = null;
  217. $iMax = null;
  218. }
  219. return new NeuronAI\Tools\ArrayProperty($sName, $sDescription, $bRequired, $oItems, $iMin, $iMax);
  220. }
  221. protected function objectProperty(string $sName, array $aSchema, bool $bRequired, ?string $sDescription): NeuronAI\Tools\ObjectProperty
  222. {
  223. return new NeuronAI\Tools\ObjectProperty($sName, $sDescription, $bRequired, null, $this->schemaProperties($aSchema));
  224. }
  225. protected function callTool(array $aCall): NeuronAI\Tools\Tool
  226. {
  227. $aFunction = isset($aCall['function']) && is_array($aCall['function']) ? $aCall['function'] : [];
  228. $sName = (string)($aFunction['name'] ?? 'tool');
  229. if ($sName === '')
  230. $sName = 'tool';
  231. $oTool = NeuronAI\Tools\Tool::make($sName, '');
  232. $sId = (string)($aCall['id'] ?? '');
  233. if ($sId !== '')
  234. $oTool->setCallId($sId);
  235. $mixedArgs = $aFunction['arguments'] ?? [];
  236. if (is_string($mixedArgs)) {
  237. $mixedDecoded = json_decode($mixedArgs, true);
  238. $mixedArgs = is_array($mixedDecoded) ? $mixedDecoded : [];
  239. }
  240. elseif (!is_array($mixedArgs))
  241. $mixedArgs = [];
  242. $oTool->setInputs($mixedArgs);
  243. return $oTool;
  244. }
  245. protected function contentBlocks(mixed $mixed): string|array|null
  246. {
  247. if (is_string($mixed) || $mixed === null)
  248. return $mixed;
  249. if (!is_array($mixed) || $mixed === [])
  250. return '';
  251. if (!array_is_list($mixed))
  252. $mixed = [$mixed];
  253. $aBlocks = [];
  254. foreach ($mixed as $aBlock) {
  255. if (!is_array($aBlock))
  256. continue;
  257. $oBlock = $this->contentBlock($aBlock);
  258. if ($oBlock)
  259. $aBlocks[] = $oBlock;
  260. }
  261. return $aBlocks === [] ? '' : $aBlocks;
  262. }
  263. protected function contentBlock(array $aBlock): ?NeuronAI\Chat\Messages\ContentBlocks\ContentBlockInterface
  264. {
  265. $sType = (string)($aBlock['type'] ?? '');
  266. if ($sType === 'text' || ($sType === '' && isset($aBlock['text'])))
  267. return new NeuronAI\Chat\Messages\ContentBlocks\TextContent((string)($aBlock['text'] ?? ''));
  268. if ($sType !== 'image_url')
  269. return null;
  270. $sUrl = '';
  271. if (isset($aBlock['image_url']) && is_array($aBlock['image_url']))
  272. $sUrl = (string)($aBlock['image_url']['url'] ?? '');
  273. elseif (isset($aBlock['image_url']))
  274. $sUrl = (string)$aBlock['image_url'];
  275. if ($sUrl === '')
  276. return null;
  277. if (preg_match('#^data:([^;]+);base64,(.+)$#s', $sUrl, $aMatch))
  278. return new NeuronAI\Chat\Messages\ContentBlocks\ImageContent($aMatch[2], NeuronAI\Chat\Enums\SourceType::BASE64, $aMatch[1]);
  279. return new NeuronAI\Chat\Messages\ContentBlocks\ImageContent($sUrl, NeuronAI\Chat\Enums\SourceType::URL);
  280. }
  281. protected function textOf(mixed $mixed): string
  282. {
  283. if (is_string($mixed))
  284. return $mixed;
  285. if (!is_array($mixed))
  286. return '';
  287. if (!array_is_list($mixed))
  288. $mixed = [$mixed];
  289. $aParts = [];
  290. foreach ($mixed as $aBlock) {
  291. if (is_string($aBlock))
  292. $aParts[] = $aBlock;
  293. elseif (is_array($aBlock))
  294. $aParts[] = (string)($aBlock['text'] ?? '');
  295. }
  296. return implode('', $aParts);
  297. }
  298. /**
  299. * @param list<NeuronAI\Chat\Messages\Message> $aMessages
  300. */
  301. protected function streamTo(NeuronAI\Providers\AIProviderInterface $oProvider, array $aMessages, string $sId, callable $fEmit): void
  302. {
  303. $bText = false;
  304. try {
  305. $oGen = $oProvider->stream(...$aMessages);
  306. foreach ($oGen as $oChunk) {
  307. if ($oChunk instanceof NeuronAI\Chat\Messages\Stream\Chunks\TextChunk && $oChunk->content !== '') {
  308. $bText = true;
  309. $fEmit($this->contentDelta($sId, $oChunk->content));
  310. }
  311. }
  312. $oMessage = $oGen->getReturn();
  313. }
  314. catch (BxAiProxyException $oException) {
  315. throw $oException;
  316. }
  317. catch (Throwable $oException) {
  318. $this->logProvider($oException);
  319. throw new BxAiProxyException(503, 'The proxy model is not available');
  320. }
  321. if (!$oMessage instanceof NeuronAI\Chat\Messages\Message)
  322. throw new BxAiProxyException(503, 'The proxy model is not available');
  323. if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage) {
  324. $this->emitToolCalls($sId, $oMessage, $fEmit);
  325. }
  326. else {
  327. if (!$bText) {
  328. $sContent = (string)$oMessage->getContent();
  329. if ($sContent !== '')
  330. $fEmit($this->contentDelta($sId, $sContent));
  331. }
  332. $fEmit($this->sse([
  333. 'id' => $sId,
  334. 'object' => 'chat.completion.chunk',
  335. 'choices' => [[
  336. 'index' => 0,
  337. 'delta' => new stdClass(),
  338. 'finish_reason' => 'stop',
  339. ]],
  340. ]));
  341. }
  342. $fEmit("data: [DONE]\n\n");
  343. }
  344. protected function emitToolCalls(string $sId, NeuronAI\Chat\Messages\ToolCallMessage $oMessage, callable $fEmit): void
  345. {
  346. $iIndex = 0;
  347. foreach ($oMessage->getTools() as $oTool) {
  348. $sCallId = (string)($oTool->getCallId() ?? ('call_' . $iIndex));
  349. $aInputs = $oTool->getInputs();
  350. $sArgs = json_encode($aInputs === [] ? new stdClass() : $aInputs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
  351. $fEmit($this->sse([
  352. 'id' => $sId,
  353. 'object' => 'chat.completion.chunk',
  354. 'choices' => [[
  355. 'index' => 0,
  356. 'delta' => [
  357. 'tool_calls' => [[
  358. 'index' => $iIndex,
  359. 'id' => $sCallId,
  360. 'type' => 'function',
  361. 'function' => [
  362. 'name' => $oTool->getName(),
  363. 'arguments' => '',
  364. ],
  365. ]],
  366. ],
  367. 'finish_reason' => null,
  368. ]],
  369. ]));
  370. $fEmit($this->sse([
  371. 'id' => $sId,
  372. 'object' => 'chat.completion.chunk',
  373. 'choices' => [[
  374. 'index' => 0,
  375. 'delta' => [
  376. 'tool_calls' => [[
  377. 'index' => $iIndex,
  378. 'function' => [
  379. 'arguments' => $sArgs,
  380. ],
  381. ]],
  382. ],
  383. 'finish_reason' => null,
  384. ]],
  385. ]));
  386. $iIndex++;
  387. }
  388. $fEmit($this->sse([
  389. 'id' => $sId,
  390. 'object' => 'chat.completion.chunk',
  391. 'choices' => [[
  392. 'index' => 0,
  393. 'delta' => new stdClass(),
  394. 'finish_reason' => 'tool_calls',
  395. ]],
  396. ]));
  397. }
  398. protected function completionArray(string $sId, NeuronAI\Chat\Messages\Message $oMessage): array
  399. {
  400. $aMessage = [
  401. 'role' => 'assistant',
  402. 'content' => $oMessage->getContent(),
  403. ];
  404. $sFinish = 'stop';
  405. if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage) {
  406. $sFinish = 'tool_calls';
  407. $aCalls = [];
  408. foreach ($oMessage->getTools() as $oTool) {
  409. $aInputs = $oTool->getInputs();
  410. $aCalls[] = [
  411. 'id' => (string)($oTool->getCallId() ?? ''),
  412. 'type' => 'function',
  413. 'function' => [
  414. 'name' => $oTool->getName(),
  415. 'arguments' => json_encode($aInputs === [] ? new stdClass() : $aInputs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
  416. ],
  417. ];
  418. }
  419. $aMessage['tool_calls'] = $aCalls;
  420. if ($aMessage['content'] === null || $aMessage['content'] === '')
  421. $aMessage['content'] = null;
  422. }
  423. return [
  424. 'id' => $sId,
  425. 'object' => 'chat.completion',
  426. 'choices' => [[
  427. 'index' => 0,
  428. 'message' => $aMessage,
  429. 'finish_reason' => $sFinish,
  430. ]],
  431. ];
  432. }
  433. protected function contentDelta(string $sId, string $sContent): string
  434. {
  435. return $this->sse([
  436. 'id' => $sId,
  437. 'object' => 'chat.completion.chunk',
  438. 'choices' => [[
  439. 'index' => 0,
  440. 'delta' => ['content' => $sContent],
  441. 'finish_reason' => null,
  442. ]],
  443. ]);
  444. }
  445. protected function sse(array $aPayload): string
  446. {
  447. return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n\n";
  448. }
  449. protected function logProvider(Throwable $oException): void
  450. {
  451. bx_log('sys_agents', 'AI proxy provider failed: ' . $oException->getMessage(), BX_LOG_ERR);
  452. }
  453. }
  454. /** @} */