Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c27618d

Browse files
authoredJun 1, 2025
feat: add support for mistral (#291)
* [x] text generation * [x] streaming * [x] tool calling * [x] image input * [x] structured output * [x] embeddings * [x] examples * [x] documentation Implementation is basically functional, but has following flaws to be resolved: 1. tool schema differs in a breaking way from GPT - extension point is a MUST 2. duplication of stream, message conversion and tool calling 3. error handling - once again
1 parent 773a700 commit c27618d

17 files changed

+675
-1
lines changed
 

‎.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ OPENAI_API_KEY=
66
# For using Claude on Anthropic
77
ANTHROPIC_API_KEY=
88

9+
# For using Mistral
10+
MISTRAL_API_KEY=
11+
912
# For using Voyage
1013
VOYAGE_API_KEY=
1114

‎README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ $embeddings = new Embeddings();
6868
* [Google's Gemini](https://gemini.google.com/) with [Google](https://ai.google.dev/) and [OpenRouter](https://www.openrouter.com/) as Platform
6969
* [DeepSeek's R1](https://www.deepseek.com/) with [OpenRouter](https://www.openrouter.com/) as Platform
7070
* [Amazon's Nova](https://nova.amazon.com) with [AWS](https://aws.amazon.com/bedrock/) as Platform
71+
* [Mistral's Mistral](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform
7172
* Embeddings Models
7273
* [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
7374
* [Voyage's Embeddings](https://docs.voyageai.com/docs/embeddings) with [Voyage](https://www.voyageai.com/) as Platform
75+
* [Mistral Embed](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform
7476
* Other Models
7577
* [OpenAI's Dall·E](https://platform.openai.com/docs/guides/image-generation) with [OpenAI](https://platform.openai.com/docs/overview) as Platform
7678
* [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, [
137139
1. [Meta's Llama with Replicate](examples/replicate/chat-llama.php)
138140
1. [Google's Gemini with Google](examples/google/chat.php)
139141
1. [Google's Gemini with OpenRouter](examples/openrouter/chat-gemini.php)
142+
1. [Mistral's Mistral with Mistral](examples/mistral/chat-mistral.php)
140143

141144
### Tools
142145

@@ -409,7 +412,7 @@ use PhpLlm\LlmChain\Platform\Message\MessageBag;
409412

410413
// Initialize Platform & Models
411414

412-
$similaritySearch = new SimilaritySearch($embeddings, $store);
415+
$similaritySearch = new SimilaritySearch($model, $store);
413416
$toolbox = Toolbox::create($similaritySearch);
414417
$processor = new Chain($toolbox);
415418
$chain = new Chain($platform, $model, [$processor], [$processor]);
@@ -547,6 +550,7 @@ needs to be used.
547550
548551
1. [Streaming Claude](examples/anthropic/stream.php)
549552
1. [Streaming GPT](examples/openai/stream.php)
553+
1. [Streaming Mistral](examples/mistral/stream.php)
550554
551555
### Image Processing
552556
@@ -623,6 +627,7 @@ dump($vectors[0]->getData()); // Array of float values
623627
624628
1. [OpenAI's Emebddings](examples/openai/embeddings.php)
625629
1. [Voyage's Embeddings](examples/voyage/embeddings.php)
630+
1. [Mistral's Embed](examples/mistral/embeddings.php)
626631

627632
### Parallel Platform Calls
628633

‎examples/mistral/chat.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
5+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
6+
use PhpLlm\LlmChain\Platform\Message\Message;
7+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
8+
use Symfony\Component\Dotenv\Dotenv;
9+
10+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
11+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
12+
13+
if (empty($_ENV['MISTRAL_API_KEY'])) {
14+
echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL;
15+
exit(1);
16+
}
17+
18+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
19+
$model = new Mistral();
20+
$chain = new Chain($platform, $model);
21+
22+
$messages = new MessageBag(Message::ofUser('What is the best French cheese?'));
23+
$response = $chain->call($messages, [
24+
'temperature' => 0.7,
25+
]);
26+
27+
echo $response->getContent().\PHP_EOL;

‎examples/mistral/embeddings.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings;
4+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
5+
use PhpLlm\LlmChain\Platform\Response\VectorResponse;
6+
use Symfony\Component\Dotenv\Dotenv;
7+
8+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
9+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
10+
11+
if (empty($_ENV['MISTRAL_API_KEY'])) {
12+
echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL;
13+
exit(1);
14+
}
15+
16+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
17+
$model = new Embeddings();
18+
19+
$response = $platform->request($model, <<<TEXT
20+
In the middle of the 20th century, food scientists began to understand the importance of vitamins and minerals in
21+
human health. They discovered that certain nutrients were essential for growth, development, and overall well-being.
22+
This led to the fortification of foods with vitamins and minerals, such as adding vitamin D to milk and iodine to
23+
salt. The goal was to prevent deficiencies and promote better health in the population.
24+
TEXT);
25+
26+
assert($response instanceof VectorResponse);
27+
28+
echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL;

‎examples/mistral/image.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
5+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
6+
use PhpLlm\LlmChain\Platform\Message\Content\Image;
7+
use PhpLlm\LlmChain\Platform\Message\Message;
8+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
9+
use Symfony\Component\Dotenv\Dotenv;
10+
11+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
12+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
13+
14+
if (empty($_ENV['OPENAI_API_KEY'])) {
15+
echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL;
16+
exit(1);
17+
}
18+
19+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
20+
$model = new Mistral(Mistral::MISTRAL_SMALL);
21+
$chain = new Chain($platform, $model);
22+
23+
$messages = new MessageBag(
24+
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
25+
Message::ofUser(
26+
'Describe the image as a comedian would do it.',
27+
Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'),
28+
),
29+
);
30+
$response = $chain->call($messages);
31+
32+
echo $response->getContent().\PHP_EOL;

‎examples/mistral/stream.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
5+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
6+
use PhpLlm\LlmChain\Platform\Message\Message;
7+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
8+
use Symfony\Component\Dotenv\Dotenv;
9+
10+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
11+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
12+
13+
if (empty($_ENV['MISTRAL_API_KEY'])) {
14+
echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL;
15+
exit(1);
16+
}
17+
18+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
19+
$model = new Mistral();
20+
$chain = new Chain($platform, $model);
21+
22+
$messages = new MessageBag(Message::ofUser('What is the eighth prime number?'));
23+
$response = $chain->call($messages, [
24+
'stream' => true,
25+
]);
26+
27+
foreach ($response->getContent() as $word) {
28+
echo $word;
29+
}
30+
echo \PHP_EOL;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Chain\StructuredOutput\ChainProcessor;
5+
use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory;
6+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
8+
use PhpLlm\LlmChain\Platform\Message\Message;
9+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
10+
use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning;
11+
use Symfony\Component\Dotenv\Dotenv;
12+
use Symfony\Component\Serializer\Encoder\JsonEncoder;
13+
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
14+
use Symfony\Component\Serializer\Serializer;
15+
16+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
17+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
18+
19+
if (empty($_ENV['MISTRAL_API_KEY'])) {
20+
echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL;
21+
exit(1);
22+
}
23+
24+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
25+
$model = new Mistral(Mistral::MISTRAL_SMALL);
26+
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
27+
28+
$processor = new ChainProcessor(new ResponseFormatFactory(), $serializer);
29+
$chain = new Chain($platform, $model, [$processor], [$processor]);
30+
$messages = new MessageBag(
31+
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
32+
Message::ofUser('how can I solve 8x + 7 = -23'),
33+
);
34+
$response = $chain->call($messages, ['output_structure' => MathReasoning::class]);
35+
36+
dump($response->getContent());

‎examples/mistral/toolcall-stream.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor;
5+
use PhpLlm\LlmChain\Chain\Toolbox\Tool\YouTubeTranscriber;
6+
use PhpLlm\LlmChain\Chain\Toolbox\Toolbox;
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
9+
use PhpLlm\LlmChain\Platform\Message\Message;
10+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
11+
use Symfony\Component\Dotenv\Dotenv;
12+
use Symfony\Component\HttpClient\HttpClient;
13+
14+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
15+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
16+
17+
if (empty($_ENV['MISTRAL_API_KEY'])) {
18+
echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL;
19+
exit(1);
20+
}
21+
22+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
23+
$model = new Mistral();
24+
25+
$transcriber = new YouTubeTranscriber(HttpClient::create());
26+
$toolbox = Toolbox::create($transcriber);
27+
$processor = new ChainProcessor($toolbox);
28+
$chain = new Chain($platform, $model, [$processor], [$processor]);
29+
30+
$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s'));
31+
$response = $chain->call($messages, [
32+
'stream' => true,
33+
]);
34+
35+
foreach ($response->getContent() as $word) {
36+
echo $word;
37+
}
38+
echo \PHP_EOL;

‎examples/mistral/toolcall.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Chain\Chain;
4+
use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor;
5+
use PhpLlm\LlmChain\Chain\Toolbox\Tool\Clock;
6+
use PhpLlm\LlmChain\Chain\Toolbox\Toolbox;
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\PlatformFactory;
9+
use PhpLlm\LlmChain\Platform\Message\Message;
10+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
11+
use Symfony\Component\Dotenv\Dotenv;
12+
13+
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
14+
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
15+
16+
if (empty($_ENV['MISTRAL_API_KEY'])) {
17+
echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL;
18+
exit(1);
19+
}
20+
21+
$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']);
22+
$model = new Mistral();
23+
24+
$toolbox = Toolbox::create(new Clock());
25+
$processor = new ChainProcessor($toolbox);
26+
$chain = new Chain($platform, $model, [$processor], [$processor]);
27+
28+
$messages = new MessageBag(Message::ofUser('What time is it?'));
29+
$response = $chain->call($messages);
30+
31+
echo $response->getContent().\PHP_EOL;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\Contract;
4+
5+
use PhpLlm\LlmChain\Platform\Contract\Normalizer\ToolNormalizer as BaseToolNormalizer;
6+
7+
class ToolNormalizer extends BaseToolNormalizer
8+
{
9+
public function normalize(mixed $data, ?string $format = null, array $context = []): array
10+
{
11+
$array = parent::normalize($data, $format, $context);
12+
13+
$array['function']['parameters'] ??= ['type' => 'object'];
14+
15+
return $array;
16+
}
17+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral;
6+
7+
use PhpLlm\LlmChain\Platform\Capability;
8+
use PhpLlm\LlmChain\Platform\Model;
9+
10+
final class Embeddings extends Model
11+
{
12+
public const MISTRAL_EMBED = 'mistral-embed';
13+
14+
/**
15+
* @param array<string, mixed> $options
16+
*/
17+
public function __construct(
18+
string $name = self::MISTRAL_EMBED,
19+
array $options = [],
20+
) {
21+
parent::__construct($name, [Capability::INPUT_MULTIPLE], $options);
22+
}
23+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings;
8+
use PhpLlm\LlmChain\Platform\Model;
9+
use PhpLlm\LlmChain\Platform\ModelClientInterface;
10+
use Symfony\Component\HttpClient\EventSourceHttpClient;
11+
use Symfony\Contracts\HttpClient\HttpClientInterface;
12+
use Symfony\Contracts\HttpClient\ResponseInterface;
13+
14+
final readonly class ModelClient implements ModelClientInterface
15+
{
16+
private EventSourceHttpClient $httpClient;
17+
18+
public function __construct(
19+
HttpClientInterface $httpClient,
20+
#[\SensitiveParameter]
21+
private string $apiKey,
22+
) {
23+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
24+
}
25+
26+
public function supports(Model $model): bool
27+
{
28+
return $model instanceof Embeddings;
29+
}
30+
31+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
32+
{
33+
return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/embeddings', [
34+
'auth_bearer' => $this->apiKey,
35+
'headers' => [
36+
'Content-Type' => 'application/json',
37+
],
38+
'json' => array_merge($options, [
39+
'model' => $model->getName(),
40+
'input' => $payload,
41+
]),
42+
]);
43+
}
44+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings;
8+
use PhpLlm\LlmChain\Platform\Exception\RuntimeException;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\Response\VectorResponse;
11+
use PhpLlm\LlmChain\Platform\ResponseConverterInterface;
12+
use PhpLlm\LlmChain\Platform\Vector\Vector;
13+
use Symfony\Contracts\HttpClient\ResponseInterface;
14+
15+
final readonly class ResponseConverter implements ResponseConverterInterface
16+
{
17+
public function supports(Model $model): bool
18+
{
19+
return $model instanceof Embeddings;
20+
}
21+
22+
public function convert(ResponseInterface $response, array $options = []): VectorResponse
23+
{
24+
$data = $response->toArray(false);
25+
26+
if (200 !== $response->getStatusCode()) {
27+
throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $response->getStatusCode(), $response->getContent(false)));
28+
}
29+
30+
if (!isset($data['data'])) {
31+
throw new RuntimeException('Response does not contain data');
32+
}
33+
34+
return new VectorResponse(
35+
...array_map(
36+
static fn (array $item): Vector => new Vector($item['embedding']),
37+
$data['data']
38+
),
39+
);
40+
}
41+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Model;
9+
use PhpLlm\LlmChain\Platform\ModelClientInterface;
10+
use Symfony\Component\HttpClient\EventSourceHttpClient;
11+
use Symfony\Contracts\HttpClient\HttpClientInterface;
12+
use Symfony\Contracts\HttpClient\ResponseInterface;
13+
14+
final readonly class ModelClient implements ModelClientInterface
15+
{
16+
private EventSourceHttpClient $httpClient;
17+
18+
public function __construct(
19+
HttpClientInterface $httpClient,
20+
#[\SensitiveParameter]
21+
private string $apiKey,
22+
) {
23+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
24+
}
25+
26+
public function supports(Model $model): bool
27+
{
28+
return $model instanceof Mistral;
29+
}
30+
31+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
32+
{
33+
return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [
34+
'auth_bearer' => $this->apiKey,
35+
'headers' => [
36+
'Content-Type' => 'application/json',
37+
'Accept' => 'application/json',
38+
],
39+
'json' => array_merge($options, $payload),
40+
]);
41+
}
42+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Exception\RuntimeException;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\Response\Choice;
11+
use PhpLlm\LlmChain\Platform\Response\ChoiceResponse;
12+
use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse;
13+
use PhpLlm\LlmChain\Platform\Response\StreamResponse;
14+
use PhpLlm\LlmChain\Platform\Response\TextResponse;
15+
use PhpLlm\LlmChain\Platform\Response\ToolCall;
16+
use PhpLlm\LlmChain\Platform\Response\ToolCallResponse;
17+
use PhpLlm\LlmChain\Platform\ResponseConverterInterface;
18+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
19+
use Symfony\Component\HttpClient\EventSourceHttpClient;
20+
use Symfony\Component\HttpClient\Exception\JsonException;
21+
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
22+
23+
final readonly class ResponseConverter implements ResponseConverterInterface
24+
{
25+
public function supports(Model $model): bool
26+
{
27+
return $model instanceof Mistral;
28+
}
29+
30+
/**
31+
* @param array<string, mixed> $options
32+
*/
33+
public function convert(HttpResponse $response, array $options = []): LlmResponse
34+
{
35+
if ($options['stream'] ?? false) {
36+
return new StreamResponse($this->convertStream($response));
37+
}
38+
39+
$code = $response->getStatusCode();
40+
$data = $response->toArray(false);
41+
42+
if (200 !== $code) {
43+
throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $code, $response->getContent(false)));
44+
}
45+
46+
if (!isset($data['choices'])) {
47+
throw new RuntimeException('Response does not contain choices');
48+
}
49+
50+
/** @var Choice[] $choices */
51+
$choices = array_map($this->convertChoice(...), $data['choices']);
52+
53+
if (1 !== \count($choices)) {
54+
return new ChoiceResponse(...$choices);
55+
}
56+
57+
if ($choices[0]->hasToolCall()) {
58+
return new ToolCallResponse(...$choices[0]->getToolCalls());
59+
}
60+
61+
return new TextResponse($choices[0]->getContent());
62+
}
63+
64+
private function convertStream(HttpResponse $response): \Generator
65+
{
66+
$toolCalls = [];
67+
foreach ((new EventSourceHttpClient())->stream($response) as $chunk) {
68+
if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) {
69+
continue;
70+
}
71+
72+
try {
73+
$data = $chunk->getArrayData();
74+
} catch (JsonException) {
75+
// try catch only needed for Symfony 6.4
76+
continue;
77+
}
78+
79+
if ($this->streamIsToolCall($data)) {
80+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
81+
}
82+
83+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
84+
yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls));
85+
}
86+
87+
if (!isset($data['choices'][0]['delta']['content'])) {
88+
continue;
89+
}
90+
91+
yield $data['choices'][0]['delta']['content'];
92+
}
93+
}
94+
95+
/**
96+
* @param array<string, mixed> $toolCalls
97+
* @param array<string, mixed> $data
98+
*
99+
* @return array<string, mixed>
100+
*/
101+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
102+
{
103+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
104+
return $toolCalls;
105+
}
106+
107+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
108+
if (isset($toolCall['id'])) {
109+
// initialize tool call
110+
$toolCalls[$i] = [
111+
'id' => $toolCall['id'],
112+
'function' => $toolCall['function'],
113+
];
114+
continue;
115+
}
116+
117+
// add arguments delta to tool call
118+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
119+
}
120+
121+
return $toolCalls;
122+
}
123+
124+
/**
125+
* @param array<string, mixed> $data
126+
*/
127+
private function streamIsToolCall(array $data): bool
128+
{
129+
return isset($data['choices'][0]['delta']['tool_calls']);
130+
}
131+
132+
/**
133+
* @param array<string, mixed> $data
134+
*/
135+
private function isToolCallsStreamFinished(array $data): bool
136+
{
137+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
138+
}
139+
140+
/**
141+
* @param array{
142+
* index: integer,
143+
* message: array{
144+
* role: 'assistant',
145+
* content: ?string,
146+
* tool_calls: array{
147+
* id: string,
148+
* type: 'function',
149+
* function: array{
150+
* name: string,
151+
* arguments: string
152+
* },
153+
* },
154+
* refusal: ?mixed
155+
* },
156+
* logprobs: string,
157+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
158+
* } $choice
159+
*/
160+
private function convertChoice(array $choice): Choice
161+
{
162+
if ('tool_calls' === $choice['finish_reason']) {
163+
return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
164+
}
165+
166+
if ('stop' === $choice['finish_reason']) {
167+
return new Choice($choice['message']['content']);
168+
}
169+
170+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
171+
}
172+
173+
/**
174+
* @param array{
175+
* id: string,
176+
* type: 'function',
177+
* function: array{
178+
* name: string,
179+
* arguments: string
180+
* }
181+
* } $toolCall
182+
*/
183+
private function convertToolCall(array $toolCall): ToolCall
184+
{
185+
$arguments = json_decode((string) $toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR);
186+
187+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
188+
}
189+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral;
6+
7+
use PhpLlm\LlmChain\Platform\Capability;
8+
use PhpLlm\LlmChain\Platform\Model;
9+
10+
final class Mistral extends Model
11+
{
12+
public const CODESTRAL = 'codestral-latest';
13+
public const CODESTRAL_MAMBA = 'open-codestral-mamba';
14+
public const MISTRAL_LARGE = 'mistral-large-latest';
15+
public const MISTRAL_SMALL = 'mistral-small-latest';
16+
public const MISTRAL_NEMO = 'open-mistral-nemo';
17+
public const MISTRAL_SABA = 'mistral-saba-latest';
18+
public const MINISTRAL_3B = 'mistral-3b-latest';
19+
public const MINISTRAL_8B = 'mistral-8b-latest';
20+
public const PIXSTRAL_LARGE = 'pixstral-large-latest';
21+
public const PIXSTRAL = 'pixstral-12b-latest';
22+
23+
/**
24+
* @param array<string, mixed> $options
25+
*/
26+
public function __construct(
27+
string $name = self::MISTRAL_LARGE,
28+
array $options = [],
29+
) {
30+
$capabilities = [
31+
Capability::INPUT_MESSAGES,
32+
Capability::OUTPUT_TEXT,
33+
Capability::OUTPUT_STREAMING,
34+
Capability::OUTPUT_STRUCTURED,
35+
];
36+
37+
if (\in_array($name, [self::PIXSTRAL, self::PIXSTRAL_LARGE, self::MISTRAL_SMALL], true)) {
38+
$capabilities[] = Capability::INPUT_IMAGE;
39+
}
40+
41+
if (\in_array($name, [
42+
self::CODESTRAL,
43+
self::MISTRAL_LARGE,
44+
self::MISTRAL_SMALL,
45+
self::MISTRAL_NEMO,
46+
self::MINISTRAL_3B,
47+
self::MINISTRAL_8B,
48+
self::PIXSTRAL,
49+
self::PIXSTRAL_LARGE,
50+
], true)) {
51+
$capabilities[] = Capability::TOOL_CALLING;
52+
}
53+
54+
parent::__construct($name, $capabilities, $options);
55+
}
56+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Contract\ToolNormalizer;
8+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings\ModelClient as EmbeddingsModelClient;
9+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
10+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm\ModelClient as MistralModelClient;
11+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm\ResponseConverter as MistralResponseConverter;
12+
use PhpLlm\LlmChain\Platform\Contract;
13+
use PhpLlm\LlmChain\Platform\Platform;
14+
use Symfony\Component\HttpClient\EventSourceHttpClient;
15+
use Symfony\Contracts\HttpClient\HttpClientInterface;
16+
17+
final class PlatformFactory
18+
{
19+
public static function create(
20+
#[\SensitiveParameter]
21+
string $apiKey,
22+
?HttpClientInterface $httpClient = null,
23+
): Platform {
24+
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
25+
26+
return new Platform(
27+
[new EmbeddingsModelClient($httpClient, $apiKey), new MistralModelClient($httpClient, $apiKey)],
28+
[new EmbeddingsResponseConverter(), new MistralResponseConverter()],
29+
Contract::create(new ToolNormalizer()),
30+
);
31+
}
32+
}

0 commit comments

Comments
 (0)
This repository has been archived.