diff --git a/.env b/.env index 64be7031..79f19d23 100644 --- a/.env +++ b/.env @@ -6,6 +6,9 @@ OPENAI_API_KEY= # For using Claude on Anthropic ANTHROPIC_API_KEY= +# For using Mistral +MISTRAL_API_KEY= + # For using Voyage VOYAGE_API_KEY= diff --git a/README.md b/README.md index fd06ca20..94aa9031 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,11 @@ $embeddings = new Embeddings(); * [Google's Gemini](https://gemini.google.com/) with [Google](https://ai.google.dev/) and [OpenRouter](https://www.openrouter.com/) as Platform * [DeepSeek's R1](https://www.deepseek.com/) with [OpenRouter](https://www.openrouter.com/) as Platform * [Amazon's Nova](https://nova.amazon.com) with [AWS](https://aws.amazon.com/bedrock/) as Platform + * [Mistral's Mistral](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform * Embeddings Models * [OpenAI's Text Embeddings](https://platform.openai.com/docs/guides/embeddings/embedding-models) with [OpenAI](https://platform.openai.com/docs/overview) and [Azure](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) as Platform * [Voyage's Embeddings](https://docs.voyageai.com/docs/embeddings) with [Voyage](https://www.voyageai.com/) as Platform + * [Mistral Embed](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform * Other Models * [OpenAI's DallĀ·E](https://platform.openai.com/docs/guides/image-generation) with [OpenAI](https://platform.openai.com/docs/overview) as Platform * [OpenAI's Whisper](https://platform.openai.com/docs/guides/speech-to-text) with [OpenAI](https://platform.openai.com/docs/overview) and [Azure](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) as Platform @@ -137,6 +139,7 @@ $response = $chain->call($messages, [ 1. [Meta's Llama with Replicate](examples/replicate/chat-llama.php) 1. [Google's Gemini with Google](examples/google/chat.php) 1. [Google's Gemini with OpenRouter](examples/openrouter/chat-gemini.php) +1. [Mistral's Mistral with Mistral](examples/mistral/chat-mistral.php) ### Tools @@ -409,7 +412,7 @@ use PhpLlm\LlmChain\Platform\Message\MessageBag; // Initialize Platform & Models -$similaritySearch = new SimilaritySearch($embeddings, $store); +$similaritySearch = new SimilaritySearch($model, $store); $toolbox = Toolbox::create($similaritySearch); $processor = new Chain($toolbox); $chain = new Chain($platform, $model, [$processor], [$processor]); @@ -547,6 +550,7 @@ needs to be used. 1. [Streaming Claude](examples/anthropic/stream.php) 1. [Streaming GPT](examples/openai/stream.php) +1. [Streaming Mistral](examples/mistral/stream.php) ### Image Processing @@ -623,6 +627,7 @@ dump($vectors[0]->getData()); // Array of float values 1. [OpenAI's Emebddings](examples/openai/embeddings.php) 1. [Voyage's Embeddings](examples/voyage/embeddings.php) +1. [Mistral's Embed](examples/mistral/embeddings.php) ### Parallel Platform Calls diff --git a/examples/mistral/chat.php b/examples/mistral/chat.php new file mode 100644 index 00000000..61fce699 --- /dev/null +++ b/examples/mistral/chat.php @@ -0,0 +1,27 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); +$chain = new Chain($platform, $model); + +$messages = new MessageBag(Message::ofUser('What is the best French cheese?')); +$response = $chain->call($messages, [ + 'temperature' => 0.7, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/mistral/embeddings.php b/examples/mistral/embeddings.php new file mode 100644 index 00000000..13f1eee6 --- /dev/null +++ b/examples/mistral/embeddings.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Embeddings(); + +$response = $platform->request($model, <<getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/mistral/image.php b/examples/mistral/image.php new file mode 100644 index 00000000..300baee1 --- /dev/null +++ b/examples/mistral/image.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(Mistral::MISTRAL_SMALL); +$chain = new Chain($platform, $model); + +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + ), +); +$response = $chain->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/mistral/stream.php b/examples/mistral/stream.php new file mode 100644 index 00000000..0778460b --- /dev/null +++ b/examples/mistral/stream.php @@ -0,0 +1,30 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); +$chain = new Chain($platform, $model); + +$messages = new MessageBag(Message::ofUser('What is the eighth prime number?')); +$response = $chain->call($messages, [ + 'stream' => true, +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/mistral/structured-output-math.php b/examples/mistral/structured-output-math.php new file mode 100644 index 00000000..07b43498 --- /dev/null +++ b/examples/mistral/structured-output-math.php @@ -0,0 +1,36 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(Mistral::MISTRAL_SMALL); +$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + +$processor = new ChainProcessor(new ResponseFormatFactory(), $serializer); +$chain = new Chain($platform, $model, [$processor], [$processor]); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$response = $chain->call($messages, ['output_structure' => MathReasoning::class]); + +dump($response->getContent()); diff --git a/examples/mistral/toolcall-stream.php b/examples/mistral/toolcall-stream.php new file mode 100644 index 00000000..cb7814e8 --- /dev/null +++ b/examples/mistral/toolcall-stream.php @@ -0,0 +1,38 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); + +$transcriber = new YouTubeTranscriber(HttpClient::create()); +$toolbox = Toolbox::create($transcriber); +$processor = new ChainProcessor($toolbox); +$chain = new Chain($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); +$response = $chain->call($messages, [ + 'stream' => true, +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/mistral/toolcall.php b/examples/mistral/toolcall.php new file mode 100644 index 00000000..19e493d6 --- /dev/null +++ b/examples/mistral/toolcall.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); + +$toolbox = Toolbox::create(new Clock()); +$processor = new ChainProcessor($toolbox); +$chain = new Chain($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('What time is it?')); +$response = $chain->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/src/Platform/Bridge/Mistral/Contract/ToolNormalizer.php b/src/Platform/Bridge/Mistral/Contract/ToolNormalizer.php new file mode 100644 index 00000000..6a03ad11 --- /dev/null +++ b/src/Platform/Bridge/Mistral/Contract/ToolNormalizer.php @@ -0,0 +1,17 @@ + 'object']; + + return $array; + } +} diff --git a/src/Platform/Bridge/Mistral/Embeddings.php b/src/Platform/Bridge/Mistral/Embeddings.php new file mode 100644 index 00000000..dc6068d6 --- /dev/null +++ b/src/Platform/Bridge/Mistral/Embeddings.php @@ -0,0 +1,23 @@ + $options + */ + public function __construct( + string $name = self::MISTRAL_EMBED, + array $options = [], + ) { + parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + } +} diff --git a/src/Platform/Bridge/Mistral/Embeddings/ModelClient.php b/src/Platform/Bridge/Mistral/Embeddings/ModelClient.php new file mode 100644 index 00000000..f19de7b9 --- /dev/null +++ b/src/Platform/Bridge/Mistral/Embeddings/ModelClient.php @@ -0,0 +1,44 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/embeddings', [ + 'auth_bearer' => $this->apiKey, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ]); + } +} diff --git a/src/Platform/Bridge/Mistral/Embeddings/ResponseConverter.php b/src/Platform/Bridge/Mistral/Embeddings/ResponseConverter.php new file mode 100644 index 00000000..6fbc2c72 --- /dev/null +++ b/src/Platform/Bridge/Mistral/Embeddings/ResponseConverter.php @@ -0,0 +1,41 @@ +toArray(false); + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $response->getStatusCode(), $response->getContent(false))); + } + + if (!isset($data['data'])) { + throw new RuntimeException('Response does not contain data'); + } + + return new VectorResponse( + ...array_map( + static fn (array $item): Vector => new Vector($item['embedding']), + $data['data'] + ), + ); + } +} diff --git a/src/Platform/Bridge/Mistral/Llm/ModelClient.php b/src/Platform/Bridge/Mistral/Llm/ModelClient.php new file mode 100644 index 00000000..f0e4758d --- /dev/null +++ b/src/Platform/Bridge/Mistral/Llm/ModelClient.php @@ -0,0 +1,42 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Mistral; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/Platform/Bridge/Mistral/Llm/ResponseConverter.php b/src/Platform/Bridge/Mistral/Llm/ResponseConverter.php new file mode 100644 index 00000000..e57185aa --- /dev/null +++ b/src/Platform/Bridge/Mistral/Llm/ResponseConverter.php @@ -0,0 +1,189 @@ + $options + */ + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + $code = $response->getStatusCode(); + $data = $response->toArray(false); + + if (200 !== $code) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $code, $response->getContent(false))); + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices'); + } + + /** @var Choice[] $choices */ + $choices = array_map($this->convertChoice(...), $data['choices']); + + if (1 !== \count($choices)) { + return new ChoiceResponse(...$choices); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); + } + + private function convertStream(HttpResponse $response): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: integer, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): Choice + { + if ('tool_calls' === $choice['finish_reason']) { + return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if ('stop' === $choice['finish_reason']) { + return new Choice($choice['message']['content']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode((string) $toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/Platform/Bridge/Mistral/Mistral.php b/src/Platform/Bridge/Mistral/Mistral.php new file mode 100644 index 00000000..23c745e0 --- /dev/null +++ b/src/Platform/Bridge/Mistral/Mistral.php @@ -0,0 +1,56 @@ + $options + */ + public function __construct( + string $name = self::MISTRAL_LARGE, + array $options = [], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::OUTPUT_STRUCTURED, + ]; + + if (\in_array($name, [self::PIXSTRAL, self::PIXSTRAL_LARGE, self::MISTRAL_SMALL], true)) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + if (\in_array($name, [ + self::CODESTRAL, + self::MISTRAL_LARGE, + self::MISTRAL_SMALL, + self::MISTRAL_NEMO, + self::MINISTRAL_3B, + self::MINISTRAL_8B, + self::PIXSTRAL, + self::PIXSTRAL_LARGE, + ], true)) { + $capabilities[] = Capability::TOOL_CALLING; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/Platform/Bridge/Mistral/PlatformFactory.php b/src/Platform/Bridge/Mistral/PlatformFactory.php new file mode 100644 index 00000000..f52ff857 --- /dev/null +++ b/src/Platform/Bridge/Mistral/PlatformFactory.php @@ -0,0 +1,32 @@ +