| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- <?php
- /**
- * Master proxy auth and model selection. Loaded without enabling bx_ai_proxy.
- */
- class BxAiProxyTest extends BxDolTestCase
- {
- 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';
- require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyKnowledge.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 testOperatorPromptIsPrependedToTheClientSystemPrompt()
- {
- $oProvider = new BxAiProxyTestProvider();
- (new BxAiProxyCompletion('Follow the operator rules.'))->run([
- 'messages' => [
- ['role' => 'system', 'content' => 'You are the client agent.'],
- ['role' => 'developer', 'content' => 'Answer briefly.'],
- ['role' => 'user', 'content' => 'hello'],
- ],
- ], 7, function () use ($oProvider) {
- return $oProvider;
- });
- $this->assertSame("Follow the operator rules.\n\nYou are the client agent.\n\nAnswer briefly.", $oProvider->sSystem);
- $this->assertSame('hello', (string)$oProvider->aMessages[0]->getContent());
- }
- public function testOperatorPromptIsUsedWhenTheClientSendsNoSystemPrompt()
- {
- $oProvider = new BxAiProxyTestProvider();
- (new BxAiProxyCompletion('Follow the operator rules.'))->run([
- 'messages' => [
- ['role' => 'user', 'content' => 'hello'],
- ],
- ], 7, function () use ($oProvider) {
- return $oProvider;
- });
- $this->assertSame('Follow the operator rules.', $oProvider->sSystem);
- }
- public function testBlankOperatorPromptLeavesTheClientSystemPrompt()
- {
- $oProvider = new BxAiProxyTestProvider();
- (new BxAiProxyCompletion(' '))->run([
- 'messages' => [
- ['role' => 'system', 'content' => 'You are the client agent.'],
- ['role' => 'user', 'content' => 'hello'],
- ],
- ], 7, function () use ($oProvider) {
- return $oProvider;
- });
- $this->assertSame('You are the client agent.', $oProvider->sSystem);
- }
- 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']);
- }
- public function testKnowledgeUsesConfiguredStoreAndDropsEmbeddings()
- {
- $iSeenStore = 0;
- $iSeenEmbedder = 0;
- $sSeenQuery = '';
- $oEmbedder = new BxAiProxyTestEmbeddings();
- $oStore = new BxAiProxyTestVectorStore();
- $aResult = (new BxAiProxyKnowledge())->run([
- 'query' => 'how do pages work',
- 'store_id' => 99,
- ], 42, function (int $iId) use (&$iSeenStore) {
- $iSeenStore = $iId;
- return [
- 'active' => 1,
- 'type' => 'file',
- 'embedding_provider_id' => 7,
- ];
- }, function (int $iId) use (&$iSeenEmbedder, &$sSeenQuery, $oEmbedder) {
- $iSeenEmbedder = $iId;
- $oEmbedder->fOnEmbed = function (string $sText) use (&$sSeenQuery) {
- $sSeenQuery = $sText;
- };
- return $oEmbedder;
- }, function (int $iId) use ($oStore, &$iSeenStore) {
- $this->assertSame($iSeenStore, $iId);
- return $oStore;
- });
- $this->assertSame(42, $iSeenStore);
- $this->assertSame(7, $iSeenEmbedder);
- $this->assertSame('how do pages work', $sSeenQuery);
- $this->assertSame([0.5, 0.25], $oStore->aSeenEmbedding);
- $this->assertCount(1, $aResult['documents']);
- $this->assertSame(['content', 'sourceType', 'sourceName', 'score'], array_keys($aResult['documents'][0]));
- $this->assertSame('Pages are blocks.', $aResult['documents'][0]['content']);
- $this->assertSame('file', $aResult['documents'][0]['sourceType']);
- $this->assertSame('pages.md', $aResult['documents'][0]['sourceName']);
- $this->assertEqualsWithDelta(0.9, $aResult['documents'][0]['score'], 0.0001);
- }
- public function testUnaProxyKnowledgeStoreReturnsNoDocuments()
- {
- $bEmbed = false;
- $aResult = (new BxAiProxyKnowledge())->run([
- 'query' => 'pages',
- 'store_id' => 99,
- ], 42, function (int $iId) {
- $this->assertSame(42, $iId);
- return [
- 'active' => 1,
- 'type' => 'una-proxy',
- 'embedding_provider_id' => 7,
- ];
- }, function () use (&$bEmbed) {
- $bEmbed = true;
- throw new Exception('should not embed');
- }, function () {
- throw new Exception('should not open store');
- });
- $this->assertFalse($bEmbed);
- $this->assertSame(['documents' => []], $aResult);
- }
- }
- 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;
- }
- }
- class BxAiProxyTestEmbeddings implements NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
- {
- /** @var callable(string): void|null */
- public $fOnEmbed = null;
- public function embedText(string $text): array
- {
- if ($this->fOnEmbed !== null)
- ($this->fOnEmbed)($text);
- return [0.5, 0.25];
- }
- public function embedDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\Document
- {
- $document->embedding = $this->embedText($document->getContent());
- return $document;
- }
- public function embedDocuments(array $documents): array
- {
- foreach ($documents as $document)
- $this->embedDocument($document);
- return $documents;
- }
- }
- class BxAiProxyTestVectorStore implements NeuronAI\RAG\VectorStore\VectorStoreInterface
- {
- public array $aSeenEmbedding = [];
- public function addDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\VectorStore\VectorStoreInterface
- {
- return $this;
- }
- public function addDocuments(array $documents): NeuronAI\RAG\VectorStore\VectorStoreInterface
- {
- return $this;
- }
- public function deleteBySource(string $sourceType, string $sourceName): NeuronAI\RAG\VectorStore\VectorStoreInterface
- {
- return $this;
- }
- public function deleteBy(string $sourceType, ?string $sourceName = null): NeuronAI\RAG\VectorStore\VectorStoreInterface
- {
- return $this;
- }
- public function similaritySearch(array $embedding): iterable
- {
- $this->aSeenEmbedding = $embedding;
- $oDocument = new NeuronAI\RAG\Document('Pages are blocks.');
- $oDocument->sourceType = 'file';
- $oDocument->sourceName = 'pages.md';
- $oDocument->embedding = [9.9, 8.8];
- $oDocument->setScore(0.9);
- return [$oDocument];
- }
- }
|