BxAiProxyTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. <?php
  2. /**
  3. * Master proxy auth and model selection. Loaded without enabling bx_ai_proxy.
  4. */
  5. class BxAiProxyTest extends BxDolTestCase
  6. {
  7. public static function setUpBeforeClass(): void
  8. {
  9. require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyAuth.php';
  10. require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyCompletion.php';
  11. require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyKnowledge.php';
  12. }
  13. public function testMatchingKeyAndSecret()
  14. {
  15. $sBody = '{"model":"gpt-not-the-studio-model"}';
  16. $sSecret = 'client-secret';
  17. $sSign = hash_hmac('sha256', $sBody, $sSecret);
  18. $bLookup = false;
  19. $aResult = (new BxAiProxyAuth())->authenticate('site-key', $sSign, $sBody, function (string $sKey) use (&$bLookup, $sSecret) {
  20. $bLookup = true;
  21. $this->assertSame('site-key', $sKey);
  22. return ['client_id' => $sKey, 'client_secret' => $sSecret];
  23. });
  24. $this->assertTrue($bLookup);
  25. $this->assertTrue($aResult['ok']);
  26. $this->assertSame(200, $aResult['code']);
  27. }
  28. public function testWrongSecretIsUnauthorized()
  29. {
  30. $sBody = '{"model":"gpt-not-the-studio-model"}';
  31. $aResult = (new BxAiProxyAuth())->authenticate('site-key', hash_hmac('sha256', $sBody, 'other-secret'), $sBody, function () {
  32. return ['client_id' => 'site-key', 'client_secret' => 'client-secret'];
  33. });
  34. $this->assertFalse($aResult['ok']);
  35. $this->assertSame(401, $aResult['code']);
  36. }
  37. public function testEmptyKeyDoesNotLookUp()
  38. {
  39. $bLookup = false;
  40. $aResult = (new BxAiProxyAuth())->authenticate('', 'sign', '{}', function () use (&$bLookup) {
  41. $bLookup = true;
  42. return ['client_secret' => 'client-secret'];
  43. });
  44. $this->assertFalse($bLookup);
  45. $this->assertFalse($aResult['ok']);
  46. $this->assertSame(401, $aResult['code']);
  47. }
  48. public function testOperatorPromptIsPrependedToTheClientSystemPrompt()
  49. {
  50. $oProvider = new BxAiProxyTestProvider();
  51. (new BxAiProxyCompletion('Follow the operator rules.'))->run([
  52. 'messages' => [
  53. ['role' => 'system', 'content' => 'You are the client agent.'],
  54. ['role' => 'developer', 'content' => 'Answer briefly.'],
  55. ['role' => 'user', 'content' => 'hello'],
  56. ],
  57. ], 7, function () use ($oProvider) {
  58. return $oProvider;
  59. });
  60. $this->assertSame("Follow the operator rules.\n\nYou are the client agent.\n\nAnswer briefly.", $oProvider->sSystem);
  61. $this->assertSame('hello', (string)$oProvider->aMessages[0]->getContent());
  62. }
  63. public function testOperatorPromptIsUsedWhenTheClientSendsNoSystemPrompt()
  64. {
  65. $oProvider = new BxAiProxyTestProvider();
  66. (new BxAiProxyCompletion('Follow the operator rules.'))->run([
  67. 'messages' => [
  68. ['role' => 'user', 'content' => 'hello'],
  69. ],
  70. ], 7, function () use ($oProvider) {
  71. return $oProvider;
  72. });
  73. $this->assertSame('Follow the operator rules.', $oProvider->sSystem);
  74. }
  75. public function testBlankOperatorPromptLeavesTheClientSystemPrompt()
  76. {
  77. $oProvider = new BxAiProxyTestProvider();
  78. (new BxAiProxyCompletion(' '))->run([
  79. 'messages' => [
  80. ['role' => 'system', 'content' => 'You are the client agent.'],
  81. ['role' => 'user', 'content' => 'hello'],
  82. ],
  83. ], 7, function () use ($oProvider) {
  84. return $oProvider;
  85. });
  86. $this->assertSame('You are the client agent.', $oProvider->sSystem);
  87. }
  88. public function testRequestModelDoesNotSelectTheProvider()
  89. {
  90. $sBodyModel = 'gpt-not-the-studio-model';
  91. $iSeen = 0;
  92. $oProvider = new BxAiProxyTestProvider();
  93. $sJson = (new BxAiProxyCompletion())->run([
  94. 'model' => $sBodyModel,
  95. 'temperature' => 0.2,
  96. 'max_tokens' => 16,
  97. 'top_p' => 0.5,
  98. 'messages' => [
  99. ['role' => 'user', 'content' => 'hello'],
  100. ],
  101. 'stream' => false,
  102. ], 42, function (int $iId) use (&$iSeen, $oProvider) {
  103. $iSeen = $iId;
  104. return $oProvider;
  105. });
  106. $this->assertSame(42, $iSeen);
  107. $this->assertFalse($oProvider->saw($sBodyModel));
  108. $this->assertIsString($sJson);
  109. $this->assertStringNotContainsString($sBodyModel, $sJson);
  110. }
  111. public function testNestedToolSchemaKeepsArrayItemsAndObjectProperties()
  112. {
  113. $oProvider = new BxAiProxyTestProvider();
  114. (new BxAiProxyCompletion())->run([
  115. 'messages' => [
  116. ['role' => 'user', 'content' => 'search'],
  117. ],
  118. 'tools' => [[
  119. 'type' => 'function',
  120. 'function' => [
  121. 'name' => 'content_search',
  122. 'description' => 'Search content',
  123. 'parameters' => [
  124. 'type' => 'object',
  125. 'properties' => [
  126. 'keyword' => [
  127. 'type' => 'string',
  128. 'description' => 'The keyword to search for.',
  129. ],
  130. 'sections' => [
  131. 'type' => 'array',
  132. 'description' => 'List of sections to search in.',
  133. 'items' => [
  134. 'type' => 'string',
  135. 'description' => 'Section name.',
  136. ],
  137. ],
  138. 'data' => [
  139. 'type' => 'array',
  140. 'description' => 'Fields to write.',
  141. 'minItems' => 1,
  142. 'maxItems' => 8,
  143. 'items' => [
  144. 'type' => 'object',
  145. 'properties' => [
  146. 'name' => ['type' => 'string', 'description' => 'Parameter name'],
  147. 'value' => ['type' => 'string', 'description' => 'Parameter value'],
  148. ],
  149. 'required' => ['name', 'value'],
  150. ],
  151. ],
  152. ],
  153. 'required' => ['keyword', 'data'],
  154. ],
  155. ],
  156. ]],
  157. ], 7, function () use ($oProvider) {
  158. return $oProvider;
  159. });
  160. $this->assertCount(1, $oProvider->aTools);
  161. $aMapped = (new NeuronAI\Providers\OpenAI\ToolMapper())->map($oProvider->aTools);
  162. $aProperties = $aMapped[0]['function']['parameters']['properties'];
  163. $this->assertSame(['keyword', 'data'], $aMapped[0]['function']['parameters']['required']);
  164. $this->assertSame('string', $aProperties['sections']['items']['type']);
  165. $this->assertSame('Section name.', $aProperties['sections']['items']['description']);
  166. $this->assertSame('object', $aProperties['data']['items']['type']);
  167. $this->assertSame('string', $aProperties['data']['items']['properties']['name']['type']);
  168. $this->assertSame(['name', 'value'], $aProperties['data']['items']['required']);
  169. $this->assertSame(1, $aProperties['data']['minItems']);
  170. $this->assertSame(8, $aProperties['data']['maxItems']);
  171. }
  172. public function testKnowledgeUsesConfiguredStoreAndDropsEmbeddings()
  173. {
  174. $iSeenStore = 0;
  175. $iSeenEmbedder = 0;
  176. $sSeenQuery = '';
  177. $oEmbedder = new BxAiProxyTestEmbeddings();
  178. $oStore = new BxAiProxyTestVectorStore();
  179. $aResult = (new BxAiProxyKnowledge())->run([
  180. 'query' => 'how do pages work',
  181. 'store_id' => 99,
  182. ], 42, function (int $iId) use (&$iSeenStore) {
  183. $iSeenStore = $iId;
  184. return [
  185. 'active' => 1,
  186. 'type' => 'file',
  187. 'embedding_provider_id' => 7,
  188. ];
  189. }, function (int $iId) use (&$iSeenEmbedder, &$sSeenQuery, $oEmbedder) {
  190. $iSeenEmbedder = $iId;
  191. $oEmbedder->fOnEmbed = function (string $sText) use (&$sSeenQuery) {
  192. $sSeenQuery = $sText;
  193. };
  194. return $oEmbedder;
  195. }, function (int $iId) use ($oStore, &$iSeenStore) {
  196. $this->assertSame($iSeenStore, $iId);
  197. return $oStore;
  198. });
  199. $this->assertSame(42, $iSeenStore);
  200. $this->assertSame(7, $iSeenEmbedder);
  201. $this->assertSame('how do pages work', $sSeenQuery);
  202. $this->assertSame([0.5, 0.25], $oStore->aSeenEmbedding);
  203. $this->assertCount(1, $aResult['documents']);
  204. $this->assertSame(['content', 'sourceType', 'sourceName', 'score'], array_keys($aResult['documents'][0]));
  205. $this->assertSame('Pages are blocks.', $aResult['documents'][0]['content']);
  206. $this->assertSame('file', $aResult['documents'][0]['sourceType']);
  207. $this->assertSame('pages.md', $aResult['documents'][0]['sourceName']);
  208. $this->assertEqualsWithDelta(0.9, $aResult['documents'][0]['score'], 0.0001);
  209. }
  210. public function testUnaProxyKnowledgeStoreReturnsNoDocuments()
  211. {
  212. $bEmbed = false;
  213. $aResult = (new BxAiProxyKnowledge())->run([
  214. 'query' => 'pages',
  215. 'store_id' => 99,
  216. ], 42, function (int $iId) {
  217. $this->assertSame(42, $iId);
  218. return [
  219. 'active' => 1,
  220. 'type' => 'una-proxy',
  221. 'embedding_provider_id' => 7,
  222. ];
  223. }, function () use (&$bEmbed) {
  224. $bEmbed = true;
  225. throw new Exception('should not embed');
  226. }, function () {
  227. throw new Exception('should not open store');
  228. });
  229. $this->assertFalse($bEmbed);
  230. $this->assertSame(['documents' => []], $aResult);
  231. }
  232. }
  233. class BxAiProxyTestProvider implements NeuronAI\Providers\AIProviderInterface
  234. {
  235. public string $sSystem = '';
  236. /** @var list<NeuronAI\Chat\Messages\Message> */
  237. public array $aMessages = [];
  238. /** @var list<NeuronAI\Tools\ToolInterface> */
  239. public array $aTools = [];
  240. public function systemPrompt(?string $prompt): NeuronAI\Providers\AIProviderInterface
  241. {
  242. $this->sSystem = (string)$prompt;
  243. return $this;
  244. }
  245. public function setTools(array $tools): NeuronAI\Providers\AIProviderInterface
  246. {
  247. $this->aTools = $tools;
  248. return $this;
  249. }
  250. public function messageMapper(): NeuronAI\Providers\MessageMapperInterface
  251. {
  252. throw new Exception('unused');
  253. }
  254. public function toolPayloadMapper(): NeuronAI\Providers\ToolMapperInterface
  255. {
  256. throw new Exception('unused');
  257. }
  258. public function chat(NeuronAI\Chat\Messages\Message ...$messages): NeuronAI\Chat\Messages\Message
  259. {
  260. $this->aMessages = $messages;
  261. return new NeuronAI\Chat\Messages\AssistantMessage('ok');
  262. }
  263. public function stream(NeuronAI\Chat\Messages\Message ...$messages): Generator
  264. {
  265. $this->aMessages = $messages;
  266. yield new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('m', 'ok');
  267. return new NeuronAI\Chat\Messages\AssistantMessage('ok');
  268. }
  269. public function structured(array|NeuronAI\Chat\Messages\Message $messages, string $class, array $response_schema): NeuronAI\Chat\Messages\Message
  270. {
  271. throw new Exception('unused');
  272. }
  273. public function setHttpClient(NeuronAI\HttpClient\HttpClientInterface $client): NeuronAI\Providers\AIProviderInterface
  274. {
  275. return $this;
  276. }
  277. public function saw(string $sNeedle): bool
  278. {
  279. if (str_contains($this->sSystem, $sNeedle))
  280. return true;
  281. foreach ($this->aMessages as $oMessage) {
  282. if (str_contains((string)$oMessage->getContent(), $sNeedle))
  283. return true;
  284. }
  285. foreach ($this->aTools as $oTool) {
  286. if (str_contains($oTool->getName(), $sNeedle) || str_contains((string)$oTool->getDescription(), $sNeedle))
  287. return true;
  288. }
  289. return false;
  290. }
  291. }
  292. class BxAiProxyTestEmbeddings implements NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
  293. {
  294. /** @var callable(string): void|null */
  295. public $fOnEmbed = null;
  296. public function embedText(string $text): array
  297. {
  298. if ($this->fOnEmbed !== null)
  299. ($this->fOnEmbed)($text);
  300. return [0.5, 0.25];
  301. }
  302. public function embedDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\Document
  303. {
  304. $document->embedding = $this->embedText($document->getContent());
  305. return $document;
  306. }
  307. public function embedDocuments(array $documents): array
  308. {
  309. foreach ($documents as $document)
  310. $this->embedDocument($document);
  311. return $documents;
  312. }
  313. }
  314. class BxAiProxyTestVectorStore implements NeuronAI\RAG\VectorStore\VectorStoreInterface
  315. {
  316. public array $aSeenEmbedding = [];
  317. public function addDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\VectorStore\VectorStoreInterface
  318. {
  319. return $this;
  320. }
  321. public function addDocuments(array $documents): NeuronAI\RAG\VectorStore\VectorStoreInterface
  322. {
  323. return $this;
  324. }
  325. public function deleteBySource(string $sourceType, string $sourceName): NeuronAI\RAG\VectorStore\VectorStoreInterface
  326. {
  327. return $this;
  328. }
  329. public function deleteBy(string $sourceType, ?string $sourceName = null): NeuronAI\RAG\VectorStore\VectorStoreInterface
  330. {
  331. return $this;
  332. }
  333. public function similaritySearch(array $embedding): iterable
  334. {
  335. $this->aSeenEmbedding = $embedding;
  336. $oDocument = new NeuronAI\RAG\Document('Pages are blocks.');
  337. $oDocument->sourceType = 'file';
  338. $oDocument->sourceName = 'pages.md';
  339. $oDocument->embedding = [9.9, 8.8];
  340. $oDocument->setScore(0.9);
  341. return [$oDocument];
  342. }
  343. }