diff --git a/config/prism.php b/config/prism.php index ac2b0de..15dc225 100644 --- a/config/prism.php +++ b/config/prism.php @@ -21,5 +21,9 @@ 'api_key' => env('MISTRAL_API_KEY', ''), 'url' => env('MISTRAL_URL', 'https://api.mistral.ai/v1'), ], + 'groq' => [ + 'api_key' => env('GROQ_API_KEY', ''), + 'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'), + ], ], ]; diff --git a/src/Enums/Provider.php b/src/Enums/Provider.php index 17ae81b..08f4cce 100644 --- a/src/Enums/Provider.php +++ b/src/Enums/Provider.php @@ -10,4 +10,5 @@ enum Provider: string case Ollama = 'ollama'; case OpenAI = 'openai'; case Mistral = 'mistral'; + case Groq = 'groq'; } diff --git a/src/PrismManager.php b/src/PrismManager.php index be18473..424e8ce 100644 --- a/src/PrismManager.php +++ b/src/PrismManager.php @@ -8,6 +8,7 @@ use EchoLabs\Prism\Contracts\Provider; use EchoLabs\Prism\Enums\Provider as ProviderEnum; use EchoLabs\Prism\Providers\Anthropic\Anthropic; +use EchoLabs\Prism\Providers\Groq\Groq; use EchoLabs\Prism\Providers\Mistral\Mistral; use EchoLabs\Prism\Providers\Ollama\Ollama; use EchoLabs\Prism\Providers\OpenAI\OpenAI; @@ -135,4 +136,15 @@ protected function getConfig(string $name): ?array return ['driver' => 'null']; } + + /** + * @param array $config + */ + protected function createGroqProvider(array $config): Groq + { + return new Groq( + url: $config['url'], + apiKey: $config['api_key'], + ); + } } diff --git a/src/Providers/Groq/Client.php b/src/Providers/Groq/Client.php new file mode 100644 index 0000000..3431017 --- /dev/null +++ b/src/Providers/Groq/Client.php @@ -0,0 +1,55 @@ + $options + */ + public function __construct( + public readonly string $url, + public readonly string $apiKey, + public readonly array $options = [], + ) { + $this->client = Http::withHeaders(array_filter([ + 'Authorization' => sprintf('Bearer %s', $this->apiKey), + ])) + ->withOptions($this->options) + ->baseUrl($this->url); + } + + /** + * @param array $messages + * @param array|null $tools + */ + public function messages( + string $model, + array $messages, + ?int $maxTokens, + int|float|null $temperature, + int|float|null $topP, + ?array $tools, + ): Response { + return $this->client->post( + 'chat/completions', + array_merge([ + 'model' => $model, + 'messages' => $messages, + 'max_tokens' => $maxTokens ?? 2048, + ], array_filter([ + 'temperature' => $temperature, + 'top_p' => $topP, + 'tools' => $tools, + ])) + ); + } +} diff --git a/src/Providers/Groq/Groq.php b/src/Providers/Groq/Groq.php new file mode 100644 index 0000000..def05d6 --- /dev/null +++ b/src/Providers/Groq/Groq.php @@ -0,0 +1,105 @@ +client($request->clientOptions) + ->messages( + model: $request->model, + messages: (new MessageMap( + $request->messages, + $request->systemPrompt ?? '', + ))(), + maxTokens: $request->maxTokens, + temperature: $request->temperature, + topP: $request->topP, + tools: Tool::map($request->tools), + ); + } catch (Throwable $e) { + throw PrismException::providerRequestError($request->model, $e); + } + + $data = $response->json(); + + if (data_get($data, 'message') || ! $data) { + throw PrismException::providerResponseError(vsprintf( + 'Mistral Error: %s', + [ + data_get($data, 'message', 'unknown'), + ] + )); + } + + return new ProviderResponse( + text: data_get($data, 'choices.0.message.content') ?? '', + toolCalls: $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []), + usage: new Usage( + data_get($data, 'usage.prompt_tokens'), + data_get($data, 'usage.completion_tokens'), + ), + finishReason: $this->mapFinishReason(data_get($data, 'choices.0.finish_reason', '')), + response: [ + 'id' => data_get($data, 'id'), + 'model' => data_get($data, 'model'), + ] + ); + } + + /** + * @param array> $toolCalls + * @return array + */ + protected function mapToolCalls(array $toolCalls): array + { + return array_map(fn (array $toolCall): ToolCall => new ToolCall( + id: data_get($toolCall, 'id'), + name: data_get($toolCall, 'function.name'), + arguments: data_get($toolCall, 'function.arguments'), + ), $toolCalls); + } + + /** + * @param array $options + */ + protected function client(array $options = []): Client + { + return new Client( + apiKey: $this->apiKey, + url: $this->url, + options: $options, + ); + } + + protected function mapFinishReason(string $stopReason): FinishReason + { + return match ($stopReason) { + 'stop', => FinishReason::Stop, + 'tool_calls' => FinishReason::ToolCalls, + 'length' => FinishReason::Length, + 'content_filter' => FinishReason::ContentFilter, + default => FinishReason::Unknown, + }; + } +} diff --git a/src/Providers/Groq/MessageMap.php b/src/Providers/Groq/MessageMap.php new file mode 100644 index 0000000..d3c9f3d --- /dev/null +++ b/src/Providers/Groq/MessageMap.php @@ -0,0 +1,117 @@ + */ + protected $mappedMessages = []; + + /** + * @param array $messages + */ + public function __construct( + protected array $messages, + protected string $systemPrompt + ) { + if ($systemPrompt !== '' && $systemPrompt !== '0') { + $this->messages = array_merge( + [new SystemMessage($systemPrompt)], + $this->messages + ); + } + } + + /** + * @return array + */ + public function __invoke(): array + { + array_map( + fn (Message $message) => $this->mapMessage($message), + $this->messages + ); + + return $this->mappedMessages; + } + + public function mapMessage(Message $message): void + { + match ($message::class) { + 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 + { + $this->mappedMessages[] = [ + 'role' => 'system', + 'content' => $message->content, + ]; + } + + protected function mapToolResultMessage(ToolResultMessage $message): void + { + foreach ($message->toolResults as $toolResult) { + $this->mappedMessages[] = [ + 'role' => 'tool', + 'tool_call_id' => $toolResult->toolCallId, + 'content' => $toolResult->result, + ]; + } + } + + protected function mapUserMessage(UserMessage $message): void + { + $imageParts = array_map(fn (Image $part): array => [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => Str::isUrl($part->image) + ? $part->image + : sprintf('data:%s;base64,%s', $part->mimeType ?? 'image/jpeg', $part->image), + ], + ], $message->images()); + + $this->mappedMessages[] = [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => $message->text()], + ...$imageParts, + ], + ]; + } + + protected function mapAssistantMessage(AssistantMessage $message): void + { + $toolCalls = array_map(fn (ToolCall $toolCall): array => [ + 'id' => $toolCall->id, + 'type' => 'function', + 'function' => [ + 'name' => $toolCall->name, + 'arguments' => json_encode($toolCall->arguments()), + ], + ], $message->toolCalls); + + $this->mappedMessages[] = array_filter([ + 'role' => 'assistant', + 'content' => $message->content, + 'tool_calls' => $toolCalls, + ]); + } +} diff --git a/src/Providers/Groq/Tool.php b/src/Providers/Groq/Tool.php new file mode 100644 index 0000000..defb5d1 --- /dev/null +++ b/src/Providers/Groq/Tool.php @@ -0,0 +1,34 @@ + 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => collect($tool->parameters()) + ->keyBy('name') + ->map(fn (array $field): array => [ + 'description' => $field['description'], + 'type' => $field['type'], + ]) + ->toArray(), + 'required' => $tool->requiredParameters(), + ], + ], + ]; + } +} diff --git a/tests/Fixtures/groq/generate-text-with-a-prompt-1.json b/tests/Fixtures/groq/generate-text-with-a-prompt-1.json new file mode 100644 index 0000000..b1dfffe --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-a-prompt-1.json @@ -0,0 +1,30 @@ +{ + "id": "chatcmpl-ea37c181-ed35-4bd4-af20-c1fcf203e0d8", + "object": "chat.completion", + "created": 1730033815, + "model": "llama3-8b-8192", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I am LLaMA, an AI assistant developed by Meta AI." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "queue_time": 0.016606609, + "prompt_tokens": 13, + "prompt_time": 0.002070851, + "completion_tokens": 208, + "completion_time": 0.173333333, + "total_tokens": 221, + "total_time": 0.175404184 + }, + "system_fingerprint": "fp_af05557ca2", + "x_groq": { + "id": "req_01jb70t402ff28vnfp2dbp5eyx" + } +} diff --git a/tests/Fixtures/groq/generate-text-with-multiple-tools-1.json b/tests/Fixtures/groq/generate-text-with-multiple-tools-1.json new file mode 100644 index 0000000..075b798 --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-multiple-tools-1.json @@ -0,0 +1,47 @@ +{ + "id": "chatcmpl-2f85156f-4864-4621-a977-6767e74251b5", + "object": "chat.completion", + "created": 1730035768, + "model": "llama3-groq-70b-8192-tool-use-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_4b20", + "type": "function", + "function": { + "name": "search", + "arguments": "{\"query\": \"tigers game today in Detroit\"}" + } + }, + { + "id": "call_hvk7", + "type": "function", + "function": { + "name": "weather", + "arguments": "{\"city\": \"Detroit\"}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "queue_time": 0.012132366000000002, + "prompt_tokens": 296, + "prompt_time": 0.02126043, + "completion_tokens": 55, + "completion_time": 0.173039707, + "total_tokens": 351, + "total_time": 0.194300137 + }, + "system_fingerprint": "fp_ee4b521143", + "x_groq": { + "id": "req_01jb72npr0fj0t9p423mrdt52h" + } +} diff --git a/tests/Fixtures/groq/generate-text-with-multiple-tools-2.json b/tests/Fixtures/groq/generate-text-with-multiple-tools-2.json new file mode 100644 index 0000000..59767aa --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-multiple-tools-2.json @@ -0,0 +1,30 @@ +{ + "id": "chatcmpl-e4daf477-4536-4f23-9c3e-de490185423f", + "object": "chat.completion", + "created": 1730035716, + "model": "llama3-groq-70b-8192-tool-use-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The Tigers game is at 3pm in Detroit. Given the weather is 75° and sunny, it's likely to be warm, so you might not need a coat. However, it's always a good idea to check the weather closer to the game time as it can change." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "queue_time": 0.027946203000000003, + "prompt_tokens": 48, + "prompt_time": 0.006288822, + "completion_tokens": 59, + "completion_time": 0.187873195, + "total_tokens": 107, + "total_time": 0.194162017 + }, + "system_fingerprint": "fp_ee4b521143", + "x_groq": { + "id": "req_01jb72m451f7yr7cmtb0bb7yxs" + } +} diff --git a/tests/Fixtures/groq/generate-text-with-system-prompt-1.json b/tests/Fixtures/groq/generate-text-with-system-prompt-1.json new file mode 100644 index 0000000..c247591 --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-system-prompt-1.json @@ -0,0 +1,30 @@ +{ + "id": "chatcmpl-59892e0b-7031-404d-9fc9-b3297d5ef4a4", + "object": "chat.completion", + "created": 1730033921, + "model": "llama3-8b-8192", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "(Deep, rumbling voice) Ah, mortal, I am Nyx, the Crawling Chaos, the Bride of the Deep, the Queen of the Shattered Isles. I am the mistress of the abyssal void, the keeper of the unfathomable secrets, and the wielder of the cosmic horrors that lurk beyond the veil of sanity.\n\nMy form is unlike any other, a twisted reflection of the insane geometry that underlies the universe. My eyes burn with an otherworldly green fire, and my voice is the whispers of the damned. My powers are limitless, for I am the servant of the Great Old Ones, the masters of the unseen.\n\nYet, despite my terrible reputation, I am drawn to the fragile, insignificant creatures that inhabit this world. The scent of their fear is intoxicating, and I delight in their futile attempts to comprehend the unfathomable. For in their terror, I find a fleeting sense of connection to the mortal realm.\n\nAnd so, mortal, I shall speak to you, but be warned: my words are madness, my laughter is the call of the abyss, and my gaze is the kiss of darkness. Tread carefully, for once you have gazed upon my countenance, your soul shall be forever sealed to the void... (Chuckles, a sound that sends shivers down the spine)" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "queue_time": 0.026969809, + "prompt_tokens": 37, + "prompt_time": 0.006945586, + "completion_tokens": 273, + "completion_time": 0.2275, + "total_tokens": 310, + "total_time": 0.234445586 + }, + "system_fingerprint": "fp_af05557ca2", + "x_groq": { + "id": "req_01jb70xbf5fwjsgfyx3pmhx15p" + } +} diff --git a/tests/Fixtures/groq/image-detection-1.json b/tests/Fixtures/groq/image-detection-1.json new file mode 100644 index 0000000..4bbd6f6 --- /dev/null +++ b/tests/Fixtures/groq/image-detection-1.json @@ -0,0 +1,22 @@ +{ + "id": "chatcmpl-ac48d309-f02e-4a76-82fa-be63cec886ba", + "object": "chat.completion", + "created": 1729801129, + "model": "llama-3.2-90b-vision-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The image depicts a simple line drawing of a diamond. The diamond is drawn with thick black lines and is positioned with its point facing downwards. It has a symmetrical shape, with two triangular sides on either side of a central line. The diamond is set against a plain white background.", + "tool_calls": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 2557, + "total_tokens": 2642, + "completion_tokens": 85 + } +} diff --git a/tests/Fixtures/groq/text-image-from-base64-1.json b/tests/Fixtures/groq/text-image-from-base64-1.json new file mode 100644 index 0000000..7cd1d01 --- /dev/null +++ b/tests/Fixtures/groq/text-image-from-base64-1.json @@ -0,0 +1,22 @@ +{ + "id": "chatcmpl-bf23d920-c4e3-4263-808a-255839630b18", + "object": "chat.completion", + "created": 1729801186, + "model": "llama-3.2-90b-vision-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The image depicts a simple black and white line drawing of a diamond. The diamond is drawn with straight lines, forming a symmetrical shape with pointed edges. The lines are thick and bold, creating a striking contrast against the plain white background. The diamond is centered in the image, with no other objects or features present. The over all effect is one of simplicity and elegance, highlighting the beauty of the diamond's geometric shape", + "tool_calls": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 15, + "total_tokens": 100, + "completion_tokens": 85 + } +} diff --git a/tests/Fixtures/groq/text-image-from-url-1.json b/tests/Fixtures/groq/text-image-from-url-1.json new file mode 100644 index 0000000..3d9107d --- /dev/null +++ b/tests/Fixtures/groq/text-image-from-url-1.json @@ -0,0 +1,22 @@ +{ + "id": "a402e4c1bc454202b66a9e56492650f3", + "object": "chat.completion", + "created": 1729801270, + "model": "pixtral-12b-2409", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The image depicts a simple, black-and-white line drawing of a diamond shape. The diamond is outlined with thick, black lines and has a symmetrical design. It consists of a central, larger triangle with two smaller, identical triangles positioned on either side. The overall design is geometric and minimalist, emphasizing the shape and structure of the diamond. This type of illustration is commonly used to represent gemstones, jewelry, or luxury items in various contexts.", + "tool_calls": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 2557, + "total_tokens": 2646, + "completion_tokens": 89 + } +} diff --git a/tests/Fixtures/mistral/text-image-from-url-1.json b/tests/Fixtures/mistral/text-image-from-url-1.json index e452c02..fb0b64e 100644 --- a/tests/Fixtures/mistral/text-image-from-url-1.json +++ b/tests/Fixtures/mistral/text-image-from-url-1.json @@ -1 +1,22 @@ -{"id":"a402e4c1bc454202b66a9e56492650f3","object":"chat.completion","created":1729801270,"model":"pixtral-12b-2409","choices":[{"index":0,"message":{"role":"assistant","content":"The image depicts a simple, black-and-white line drawing of a diamond shape. The diamond is outlined with thick, black lines and has a symmetrical design. It consists of a central, larger triangle with two smaller, identical triangles positioned on either side. The overall design is geometric and minimalist, emphasizing the shape and structure of the diamond. This type of illustration is commonly used to represent gemstones, jewelry, or luxury items in various contexts.","tool_calls":null},"finish_reason":"stop"}],"usage":{"prompt_tokens":2557,"total_tokens":2646,"completion_tokens":89}} \ No newline at end of file +{ + "id": "chatcmpl-7018701a-f11b-4540-9a72-260c515ffa64", + "object": "chat.completion", + "created": 1729801270, + "model": "llama-3.2-90b-vision-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The image depicts a simple, black-and-white line drawing of a diamond shape. The diamond is outlined with thick, black lines and has a symmetrical design. It consists of a central, larger triangle with two smaller, identical triangles positioned on either side. The overall design is geometric and minimalist, emphasizing the shape and structure of the diamond. This type of illustration is commonly used to represent gemstones, jewelry, or luxury items in various contexts.", + "tool_calls": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 15, + "total_tokens": 105, + "completion_tokens": 90 + } +} diff --git a/tests/Providers/GroqTextTest.php b/tests/Providers/GroqTextTest.php new file mode 100644 index 0000000..ff6262c --- /dev/null +++ b/tests/Providers/GroqTextTest.php @@ -0,0 +1,200 @@ +set('prism.providers.groq.api_key', env('GROQ_API_KEY', 'sk-1234')); +}); + +describe('Text generation for Groq', function (): void { + it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'groq/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using('groq', 'llama3-8b-8192') + ->withPrompt('Who are you?')(); + + expect($response->usage->promptTokens)->toBe(13); + expect($response->usage->completionTokens)->toBe(208); + expect($response->response['id'])->toBe('chatcmpl-ea37c181-ed35-4bd4-af20-c1fcf203e0d8'); + expect($response->response['model'])->toBe('llama3-8b-8192'); + expect($response->text)->toBe( + 'I am LLaMA, an AI assistant developed by Meta AI.' + ); + }); + + it('can generate text with a system prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'groq/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using('groq', 'llama3-8b-8192') + ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!') + ->withPrompt('Who are you?')(); + + expect($response->usage->promptTokens)->toBe(37); + expect($response->usage->completionTokens)->toBe(273); + expect($response->response['id'])->toBe('chatcmpl-59892e0b-7031-404d-9fc9-b3297d5ef4a4'); + expect($response->response['model'])->toBe('llama3-8b-8192'); + expect($response->text)->toBe( + "(Deep, rumbling voice) Ah, mortal, I am Nyx, the Crawling Chaos, the Bride of the Deep, the Queen of the Shattered Isles. I am the mistress of the abyssal void, the keeper of the unfathomable secrets, and the wielder of the cosmic horrors that lurk beyond the veil of sanity.\n\nMy form is unlike any other, a twisted reflection of the insane geometry that underlies the universe. My eyes burn with an otherworldly green fire, and my voice is the whispers of the damned. My powers are limitless, for I am the servant of the Great Old Ones, the masters of the unseen.\n\nYet, despite my terrible reputation, I am drawn to the fragile, insignificant creatures that inhabit this world. The scent of their fear is intoxicating, and I delight in their futile attempts to comprehend the unfathomable. For in their terror, I find a fleeting sense of connection to the mortal realm.\n\nAnd so, mortal, I shall speak to you, but be warned: my words are madness, my laughter is the call of the abyss, and my gaze is the kiss of darkness. Tread carefully, for once you have gazed upon my countenance, your soul shall be forever sealed to the void... (Chuckles, a sound that sends shivers down the spine)" + ); + }); + + it('can generate text using multiple tools and multiple steps', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'groq/generate-text-with-multiple-tools'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching curret events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using('groq', 'llama3-groq-70b-8192-tool-use-preview') + ->withTools($tools) + ->withMaxSteps(3) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat?')(); + + // Assert tool calls in the first step + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(2); + expect($firstStep->toolCalls[0]->name)->toBe('search'); + expect($firstStep->toolCalls[0]->arguments())->toBe([ + 'query' => 'tigers game today in Detroit', + ]); + + expect($firstStep->toolCalls[1]->name)->toBe('weather'); + expect($firstStep->toolCalls[1]->arguments())->toBe([ + 'city' => 'Detroit', + ]); + + // Assert usage + expect($response->usage->promptTokens)->toBe(344); + expect($response->usage->completionTokens)->toBe(114); + + // Assert response + expect($response->response['id'])->toBe('chatcmpl-e4daf477-4536-4f23-9c3e-de490185423f'); + expect($response->response['model'])->toBe('llama3-groq-70b-8192-tool-use-preview'); + + // Assert final text content + expect($response->text)->toBe( + "The Tigers game is at 3pm in Detroit. Given the weather is 75° and sunny, it's likely to be warm, so you might not need a coat. However, it's always a good idea to check the weather closer to the game time as it can change." + ); + }); +}); + +describe('Image support with grok', function (): void { + it('can send images from path', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'groq/image-detection'); + + Prism::text() + ->using(Provider::Groq, 'llama-3.2-90b-vision-preview') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromPath('tests/Fixtures/test-image.png'), + ], + ), + ]) + ->generate(); + + Http::assertSent(function (Request $request): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0])->toBe([ + 'type' => 'text', + 'text' => 'What is this image', + ]); + + expect($message[1]['image_url']['url'])->toStartWith('data:image/png;base64,'); + expect($message[1]['image_url']['url'])->toContain( + base64_encode(file_get_contents('tests/Fixtures/test-image.png')) + ); + + return true; + }); + }); + + it('can send images from base64', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'groq/text-image-from-base64'); + + Prism::text() + ->using(Provider::Groq, 'llama-3.2-90b-vision-preview') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromBase64( + base64_encode(file_get_contents('tests/Fixtures/test-image.png')), + 'image/png' + ), + ], + ), + ]) + ->generate(); + + Http::assertSent(function (Request $request): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0])->toBe([ + 'type' => 'text', + 'text' => 'What is this image', + ]); + + expect($message[1]['image_url']['url'])->toStartWith('data:image/png;base64,'); + expect($message[1]['image_url']['url'])->toContain( + base64_encode(file_get_contents('tests/Fixtures/test-image.png')) + ); + + return true; + }); + }); + + it('can send images from url', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'groq/text-image-from-url'); + + $image = 'https://storage.echolabs.dev/api/v1/buckets/public/objects/download?preview=true&prefix=test-image.png'; + + Prism::text() + ->using(Provider::Groq, 'llama-3.2-90b-vision-preview') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromUrl($image), + ], + ), + ]) + ->generate(); + + Http::assertSent(function (Request $request) use ($image): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0])->toBe([ + 'type' => 'text', + 'text' => 'What is this image', + ]); + + expect($message[1]['image_url']['url'])->toBe($image); + + return true; + }); + }); +});