diff --git a/src/Providers/Gemini/Maps/MessageMap.php b/src/Providers/Gemini/Maps/MessageMap.php index 390831525..8de0d1f1c 100644 --- a/src/Providers/Gemini/Maps/MessageMap.php +++ b/src/Providers/Gemini/Maps/MessageMap.php @@ -53,23 +53,14 @@ protected function mapMessage(Message $message): void UserMessage::class => $this->mapUserMessage($message), AssistantMessage::class => $this->mapAssistantMessage($message), ToolResultMessage::class => $this->mapToolResultMessage($message), + SystemMessage::class => $this->mapSystemMessage($message), default => throw new Exception('Could not map message type '.$message::class), }; } protected function mapSystemMessage(SystemMessage $message): void { - if (isset($this->contents['system_instruction'])) { - throw new PrismException('Gemini only supports one system instruction.'); - } - - $this->contents['system_instruction'] = [ - 'parts' => [ - [ - 'text' => $message->content, - ], - ], - ]; + $this->contents['system_instruction']['parts'][] = ['text' => $message->content]; } protected function mapToolResultMessage(ToolResultMessage $message): void diff --git a/tests/Fixtures/gemini/generate-text-with-multiple-system-prompts-1.json b/tests/Fixtures/gemini/generate-text-with-multiple-system-prompts-1.json new file mode 100644 index 000000000..23e963fef --- /dev/null +++ b/tests/Fixtures/gemini/generate-text-with-multiple-system-prompts-1.json @@ -0,0 +1,34 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I am Prism, a helpful AI assistant created by echo labs.\n" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.039341066564832418 + } + ], + "usageMetadata": { + "promptTokenCount": 22, + "candidatesTokenCount": 14, + "totalTokenCount": 36, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 22 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 14 + } + ] + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/gemini/generate-text-with-system-prompt-from-messages-1.json b/tests/Fixtures/gemini/generate-text-with-system-prompt-from-messages-1.json new file mode 100644 index 000000000..f6697790b --- /dev/null +++ b/tests/Fixtures/gemini/generate-text-with-system-prompt-from-messages-1.json @@ -0,0 +1,34 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I am Prism, a helpful AI assistant created by echo labs.\n" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.1486046314239502 + } + ], + "usageMetadata": { + "promptTokenCount": 16, + "candidatesTokenCount": 14, + "totalTokenCount": 30, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 16 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 14 + } + ] + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Providers/Gemini/GeminiTextTest.php b/tests/Providers/Gemini/GeminiTextTest.php index 652b345b5..74480b294 100644 --- a/tests/Providers/Gemini/GeminiTextTest.php +++ b/tests/Providers/Gemini/GeminiTextTest.php @@ -10,6 +10,7 @@ use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Prism; +use Prism\Prism\Providers\Gemini\Gemini; use Prism\Prism\Providers\Gemini\ValueObjects\MessagePartWithSearchGroundings; use Prism\Prism\Providers\Gemini\ValueObjects\SearchGrounding; use Prism\Prism\Tool; @@ -31,7 +32,7 @@ ->using(Provider::Gemini, 'gemini-1.5-flash') ->withPrompt('Who are you?') ->withMaxTokens(10) - ->generate(); + ->asText(); expect($response->text)->toBe( "I am a large language model, trained by Google. I am an AI, and I don't have a name, feelings, or personal experiences. My purpose is to process information and respond to a wide range of prompts and questions in a helpful and informative way.\n" @@ -50,7 +51,7 @@ ->using(Provider::Gemini, 'gemini-1.5-flash') ->withSystemPrompt('You are a helpful AI assistant named Prism generated by echolabs') ->withPrompt('Who are you?') - ->generate(); + ->asText(); expect($response->text)->toBe('I am Prism, a helpful AI assistant created by echo labs.') ->and($response->usage->promptTokens)->toBe(17) @@ -60,6 +61,45 @@ ->and($response->finishReason)->toBe(FinishReason::Stop); }); + it('can generate text with a system prompt from messages', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-system-prompt-from-messages'); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withMessages([ + new SystemMessage('You are a helpful AI assistant named Prism generated by echolabs'), + new UserMessage('Who are you?'), + ]) + ->asText(); + + expect($response->text)->toBe("I am Prism, a helpful AI assistant created by echo labs.\n") + ->and($response->usage->promptTokens)->toBe(16) + ->and($response->usage->completionTokens)->toBe(14) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->finishReason)->toBe(FinishReason::Stop); + }); + + it('can generate text with multiple system prompts', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-multiple-system-prompts'); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withMessages([ + new SystemMessage('You are a helpful AI assistant named Prism generated by echolabs'), + new SystemMessage('You are a helpful AI assistant'), + new UserMessage('Who are you?'), + ]) + ->asText(); + + expect($response->text)->toBe("I am Prism, a helpful AI assistant created by echo labs.\n") + ->and($response->usage->promptTokens)->toBe(22) + ->and($response->usage->completionTokens)->toBe(14) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->finishReason)->toBe(FinishReason::Stop); + }); + it('can generate text using multiple tools and multiple steps', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-multiple-tools'); @@ -81,28 +121,28 @@ ->withTools($tools) ->withMaxSteps(5) ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools') - ->generate(); + ->asText(); // Assert tool calls in the first step $firstStep = $response->steps[0]; - expect($firstStep->toolCalls)->toHaveCount(2); - expect($firstStep->toolCalls[0]->name)->toBe('search_games'); - expect($firstStep->toolCalls[0]->arguments())->toBe([ - 'city' => 'Detroit', - ]); - expect($firstStep->toolCalls[1]->name)->toBe('get_weather'); - expect($firstStep->toolCalls[1]->arguments())->toBe([ - 'city' => 'Detroit', - ]); + expect($firstStep->toolCalls)->toHaveCount(2) + ->and($firstStep->toolCalls[0]->name)->toBe('search_games') + ->and($firstStep->toolCalls[0]->arguments())->toBe([ + 'city' => 'Detroit', + ]) + ->and($firstStep->toolCalls[1]->name)->toBe('get_weather') + ->and($firstStep->toolCalls[1]->arguments())->toBe([ + 'city' => 'Detroit', + ]) + ->and($response->usage->promptTokens)->toBe(350) + ->and($response->usage->completionTokens)->toBe(42) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->text)->toBe('The tigers game is at 3pm today in Detroit. The weather will be 45° and cold, so you should wear a coat.'); // Assert usage (combined from both responses) - expect($response->usage->promptTokens)->toBe(350) - ->and($response->usage->completionTokens)->toBe(42); // Assert response - expect($response->meta->id)->toBe('') - ->and($response->meta->model)->toBe('gemini-1.5-flash') - ->and($response->text)->toBe('The tigers game is at 3pm today in Detroit. The weather will be 45° and cold, so you should wear a coat.'); }); it('handles specific tool choice', function (): void { @@ -126,7 +166,7 @@ ->withPrompt('Do something') ->withTools($tools) ->withToolChoice('weather') - ->generate(); + ->asText(); expect($response->steps[0]->toolCalls[0]->name)->toBe('weather'); }); @@ -146,7 +186,7 @@ ], ), ]) - ->generate(); + ->asText(); // Assert response expect($response->text)->toBe("That's an illustration of a **diamond**. More specifically, it's a stylized, geometric representation of a diamond, often used as an icon or symbol") @@ -162,13 +202,12 @@ expect($message[0])->toBe([ 'text' => 'What is this image', - ]); - - expect($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']); - expect($message[1]['inline_data']['mime_type'])->toBe('image/png'); - expect($message[1]['inline_data']['data'])->toBe( - base64_encode(file_get_contents('tests/Fixtures/test-image.png')) - ); + ]) + ->and($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']) + ->and($message[1]['inline_data']['mime_type'])->toBe('image/png') + ->and($message[1]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/test-image.png')) + ); return true; }); @@ -177,7 +216,7 @@ it('can send images from base64', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/image-detection'); - $response = Prism::text() + Prism::text() ->using(Provider::Gemini, 'gemini-1.5-flash') ->withMessages([ new UserMessage( @@ -190,20 +229,19 @@ ], ), ]) - ->generate(); + ->asText(); Http::assertSent(function (Request $request): bool { $message = $request->data()['contents'][0]['parts']; expect($message[0])->toBe([ 'text' => 'What is this image', - ]); - - expect($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']); - expect($message[1]['inline_data']['mime_type'])->toBe('image/png'); - expect($message[1]['inline_data']['data'])->toBe( - base64_encode(file_get_contents('tests/Fixtures/test-image.png')) - ); + ]) + ->and($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']) + ->and($message[1]['inline_data']['mime_type'])->toBe('image/png') + ->and($message[1]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/test-image.png')) + ); return true; }); @@ -214,7 +252,7 @@ $image = 'https://storage.echolabs.dev/api/v1/buckets/public/objects/download?preview=true&prefix=test-image.png'; - $response = Prism::text() + Prism::text() ->using(Provider::Gemini, 'gemini-1.5-flash') ->withMessages([ new UserMessage( @@ -224,20 +262,19 @@ ], ), ]) - ->generate(); + ->asText(); Http::assertSent(function (Request $request) use ($image): bool { $message = $request->data()['contents'][0]['parts']; expect($message[0])->toBe([ 'text' => 'What is this image', - ]); - - expect($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']); - expect($message[1]['inline_data']['mime_type'])->toBe('image/png'); - expect($message[1]['inline_data']['data'])->toBe( - base64_encode(file_get_contents($image)) - ); + ]) + ->and($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']) + ->and($message[1]['inline_data']['mime_type'])->toBe('image/png') + ->and($message[1]['inline_data']['data'])->toBe( + base64_encode(file_get_contents($image)) + ); return true; }); @@ -258,7 +295,7 @@ ] ), ]) - ->generate(); + ->asText(); expect($response->text)->toBe("The document is about the answer to the Ultimate Question of Life, the Universe, and Everything, which is stated to be 42. This is a reference to the science fiction series \"The Hitchhiker's Guide to the Galaxy\" by Douglas Adams.\n"); @@ -267,15 +304,12 @@ expect($message[1])->toBe([ 'text' => 'What is this document about?', - ]); - - expect($message[0]['inline_data'])->toHaveKeys(['mime_type', 'data']); - - expect($message[0]['inline_data']['mime_type'])->toBe('application/pdf'); - - expect($message[0]['inline_data']['data'])->toBe( - base64_encode(file_get_contents('tests/Fixtures/test-pdf.pdf')) - ); + ]) + ->and($message[0]['inline_data'])->toHaveKeys(['mime_type', 'data']) + ->and($message[0]['inline_data']['mime_type'])->toBe('application/pdf') + ->and($message[0]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/test-pdf.pdf')) + ); return true; }); @@ -294,7 +328,7 @@ ] ), ]) - ->generate(); + ->asText(); expect($response->text)->toBe("This document is about the number 42 and its significance, likely referencing the book \"The Hitchhiker's Guide to the Galaxy\" by Douglas Adams. In that book, a supercomputer called Deep Thought calculates that 42 is the answer to the Ultimate Question of Life, the Universe, and Everything. However, frustratingly, no one knows what the actual question *is*.\n\nTherefore, the document could be:\n\n* **An explanation of the concept of 42 within the context of *The Hitchhiker's Guide to the Galaxy***: This is the most likely scenario.\n* **A humorous exploration of possible interpretations of 42**: Playing on the ambiguity of the answer.\n* **A coincidence**: The document could be about something completely unrelated to the book, and the mention of 42 is just a bizarre coincidence. However, given the specific phrasing (\"The Answer to the Ultimate Question...\"), this is very unlikely.\n* **A piece of fan fiction or creative writing**: Using the 42 concept as a jumping-off point.\n\nIn short, it's almost certainly related to *The Hitchhiker's Guide to the Galaxy* and the significance of the number 42 within that fictional universe.\n"); @@ -303,15 +337,12 @@ expect($message[1])->toBe([ 'text' => 'What is this document about?', - ]); - - expect($message[0]['inline_data'])->toHaveKeys(['mime_type', 'data']); - - expect($message[0]['inline_data']['mime_type'])->toBe('text/plain'); - - expect($message[0]['inline_data']['data'])->toBe( - base64_encode(file_get_contents('tests/Fixtures/test-text.txt')) - ); + ]) + ->and($message[0]['inline_data'])->toHaveKeys(['mime_type', 'data']) + ->and($message[0]['inline_data']['mime_type'])->toBe('text/plain') + ->and($message[0]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/test-text.txt')) + ); return true; }); @@ -326,13 +357,13 @@ ->using(Provider::Gemini, 'gemini-2.0-flash') ->withPrompt('What is the stock price of Google right now?') ->withProviderMeta(Provider::Gemini, ['searchGrounding' => true]) - ->generate(); + ->asText(); Http::assertSent(function (Request $request): true { $data = $request->data(); - expect($data['tools'][0])->toHaveKey('google_search'); - expect($data['tools'][0]['google_search'])->toBeObject(); + expect($data['tools'][0])->toHaveKey('google_search') + ->and($data['tools'][0]['google_search'])->toBeObject(); return true; }); @@ -355,7 +386,7 @@ ->withTools($tools) ->withPrompt('What sport fixtures are on today, and will I need a coat based on today\'s weather forecast?') ->withProviderMeta(Provider::Gemini, ['searchGrounding' => true]) - ->generate(); + ->asText(); })->throws(PrismException::class, 'Use of search grounding with custom tools is not currently supported by Prism.'); it('uses search grounding where searchGrounding is true on provider meta', function (): void { @@ -365,7 +396,7 @@ ->using(Provider::Gemini, 'gemini-2.0-flash') ->withPrompt('What is the stock price of Google right now?') ->withProviderMeta(Provider::Gemini, ['searchGrounding' => true]) - ->generate(); + ->asText(); expect($response->text)->toContain('Alphabet Inc.'); }); @@ -377,25 +408,24 @@ ->using(Provider::Gemini, 'gemini-2.0-flash') ->withPrompt('What is the stock price of Google right now?') ->withProviderMeta(Provider::Gemini, ['searchGrounding' => true]) - ->generate(); - - expect($response->additionalContent)->toHaveKey('searchEntryPoint'); - expect($response->additionalContent)->toHaveKey('searchQueries'); - expect($response->additionalContent)->toHaveKey('groundingSupports'); - - expect($response->additionalContent['searchEntryPoint'])->not()->toBe(''); - expect($response->additionalContent['searchQueries'])->toHaveCount(1); - expect($response->additionalContent['groundingSupports'])->toHaveCount(4); - - expect($response->additionalContent['groundingSupports'][0])->toBeInstanceOf(MessagePartWithSearchGroundings::class); - expect($response->additionalContent['groundingSupports'][0]->text)->not()->toBe(''); - expect($response->additionalContent['groundingSupports'][0]->startIndex)->not()->toBe(0); - expect($response->additionalContent['groundingSupports'][0]->endIndex)->not()->toBe(0); - expect($response->additionalContent['groundingSupports'][0]->groundings)->toHaveCount(1); - expect($response->additionalContent['groundingSupports'][0]->groundings[0])->toBeInstanceOf(SearchGrounding::class); - expect($response->additionalContent['groundingSupports'][0]->groundings[0]->title)->not()->toBe(''); - expect($response->additionalContent['groundingSupports'][0]->groundings[0]->uri)->not()->toBe(''); - expect($response->additionalContent['groundingSupports'][0]->groundings[0]->confidence)->not()->toBe(0.0); + ->asText(); + + expect($response->additionalContent)->toHaveKey('searchEntryPoint') + ->and($response->additionalContent)->toHaveKey('searchQueries') + ->and($response->additionalContent)->toHaveKey('groundingSupports') + ->and($response->additionalContent['searchEntryPoint'])->not()->toBe('') + ->and($response->additionalContent['searchQueries'])->toHaveCount(1) + ->and($response->additionalContent['groundingSupports'])->toHaveCount(4) + ->and($response->additionalContent['groundingSupports'][0])->toBeInstanceOf(MessagePartWithSearchGroundings::class) + ->and($response->additionalContent['groundingSupports'][0]->text)->not()->toBe('') + ->and($response->additionalContent['groundingSupports'][0]->startIndex)->not()->toBe(0) + ->and($response->additionalContent['groundingSupports'][0]->endIndex)->not()->toBe(0) + ->and($response->additionalContent['groundingSupports'][0]->groundings)->toHaveCount(1) + ->and($response->additionalContent['groundingSupports'][0]->groundings[0])->toBeInstanceOf(SearchGrounding::class) + ->and($response->additionalContent['groundingSupports'][0]->groundings[0]->title)->not()->toBe('') + ->and($response->additionalContent['groundingSupports'][0]->groundings[0]->uri)->not()->toBe('') + ->and($response->additionalContent['groundingSupports'][0]->groundings[0]->confidence)->not()->toBe(0.0); + }); }); @@ -403,7 +433,7 @@ it('can use a cache object with a text request', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/use-cache-with-text'); - /** @var Gemini */ + /** @var Gemini $provider */ $provider = Prism::provider(Provider::Gemini); $object = $provider->cache( @@ -423,17 +453,16 @@ ->using(Provider::Gemini, 'gemini-1.5-flash-002') ->withProviderMeta(Provider::Gemini, ['cachedContentName' => $object->name]) ->withPrompt('In no more than 100 words, what is the document about?') - ->generate(); + ->asText(); Http::assertSentInOrder([ fn (Request $request): bool => true, fn (Request $request): bool => $request->data()['cachedContent'] === $object->name, ]); - expect($response->text)->toBe("That's the Consolidated Version of the Treaty on the Functioning of the European Union (TFEU), adopted on 25 March 1957. It outlines the principles, competencies, and policies of the European Union. The TFEU organizes the EU's functioning and details its areas of competence, including exclusive, shared, and supporting competences. It also covers non-discrimination, citizenship, and various EU policies (e.g., internal market, agriculture, and justice). Finally, it sets out institutional and financial provisions.\n"); - - expect($response->usage->promptTokens)->toBe(16); - expect($response->usage->completionTokens)->toBe(116); - expect($response->usage->cacheReadInputTokens)->toBe(88759); + expect($response->text)->toBe("That's the Consolidated Version of the Treaty on the Functioning of the European Union (TFEU), adopted on 25 March 1957. It outlines the principles, competencies, and policies of the European Union. The TFEU organizes the EU's functioning and details its areas of competence, including exclusive, shared, and supporting competences. It also covers non-discrimination, citizenship, and various EU policies (e.g., internal market, agriculture, and justice). Finally, it sets out institutional and financial provisions.\n") + ->and($response->usage->promptTokens)->toBe(16) + ->and($response->usage->completionTokens)->toBe(116) + ->and($response->usage->cacheReadInputTokens)->toBe(88759); }); }); diff --git a/tests/Providers/Gemini/MessageMapTest.php b/tests/Providers/Gemini/MessageMapTest.php index 9ba1dfac0..91fa303a2 100644 --- a/tests/Providers/Gemini/MessageMapTest.php +++ b/tests/Providers/Gemini/MessageMapTest.php @@ -4,7 +4,6 @@ namespace Tests\Providers\Gemini; -use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Gemini\Maps\MessageMap; use Prism\Prism\ValueObjects\Messages\AssistantMessage; use Prism\Prism\ValueObjects\Messages\Support\Document; @@ -219,7 +218,23 @@ ]); }); -it('throws an exception of multiple system prompts are given', function (): void { +it('maps system messages to system prompts', function (): void { + $messageMap = new MessageMap( + messages: [ + new SystemMessage('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]'), + ], + ); + + expect($messageMap())->toBe([ + 'system_instruction' => [ + 'parts' => [ + ['text' => 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]'], + ], + ], + ]); +}); + +it('can process multiple system prompts', function (): void { $messageMap = new MessageMap( messages: [], systemPrompts: [ @@ -228,5 +243,12 @@ ] ); - $messageMap(); -})->throws(PrismException::class, 'Gemini only supports one system instruction.'); + expect($messageMap())->toBe([ + 'system_instruction' => [ + 'parts' => [ + ['text' => 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]'], + ['text' => 'But my friends call my Nyx.'], + ], + ], + ]); +});