From 2560b2d05c5fe6a40cd32dda63b7edc0d2571fe6 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Thu, 11 Sep 2025 17:12:18 +0200 Subject: [PATCH 1/4] Add Docker Model Runner as Platform --- examples/.env | 3 + examples/dockermodelrunner/chat.php | 31 +++++ examples/dockermodelrunner/embeddings.php | 24 ++++ src/platform/CHANGELOG.md | 1 + .../Bridge/DockerModelRunner/Completions.php | 72 ++++++++++++ .../Completions/ModelClient.php | 46 ++++++++ .../Completions/ResultConverter.php | 40 +++++++ .../Bridge/DockerModelRunner/Embeddings.php | 54 +++++++++ .../Embeddings/ModelClient.php | 45 ++++++++ .../Embeddings/ResultConverter.php | 47 ++++++++ .../DockerModelRunner/PlatformFactory.php | 41 +++++++ .../Completions/ModelClientTest.php | 108 ++++++++++++++++++ .../Completions/ResultConverterTest.php | 34 ++++++ .../Embeddings/ModelClientTest.php | 91 +++++++++++++++ .../Embeddings/ResultConverterTest.php | 89 +++++++++++++++ 15 files changed, 726 insertions(+) create mode 100644 examples/dockermodelrunner/chat.php create mode 100644 examples/dockermodelrunner/embeddings.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Completions.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Completions/ModelClient.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Embeddings.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Embeddings/ModelClient.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/Embeddings/ResultConverter.php create mode 100644 src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php create mode 100644 src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php create mode 100644 src/platform/tests/Bridge/DockerModelRunner/Completions/ResultConverterTest.php create mode 100644 src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php create mode 100644 src/platform/tests/Bridge/DockerModelRunner/Embeddings/ResultConverterTest.php diff --git a/examples/.env b/examples/.env index 5c1fdc0b4..08b5b68bc 100644 --- a/examples/.env +++ b/examples/.env @@ -18,6 +18,9 @@ REPLICATE_API_KEY= # For using Ollama OLLAMA_HOST_URL= +# For using Docker Model Runner +DOCKER_MODEL_RUNNER_HOST_URL= + # For using GPT on Azure AZURE_OPENAI_BASEURL= AZURE_OPENAI_KEY= diff --git a/examples/dockermodelrunner/chat.php b/examples/dockermodelrunner/chat.php new file mode 100644 index 000000000..70757ac66 --- /dev/null +++ b/examples/dockermodelrunner/chat.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DOCKER_MODEL_RUNNER_HOST_URL'), http_client()); +$model = new Completions(); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$result = $agent->call($messages, [ + 'max_tokens' => 500, // specific options just for this call +]); +echo $result->getContent().\PHP_EOL; diff --git a/examples/dockermodelrunner/embeddings.php b/examples/dockermodelrunner/embeddings.php new file mode 100644 index 000000000..0c1defcfc --- /dev/null +++ b/examples/dockermodelrunner/embeddings.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; +use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DOCKER_MODEL_RUNNER_HOST_URL'), http_client()); +$response = $platform->invoke(new Embeddings(Embeddings::NOMIC_EMBED_TEXT), <<asVectors()[0]->getDimensions().\PHP_EOL; diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 56fd91e9b..73fb6d9f7 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -23,6 +23,7 @@ CHANGELOG - LM Studio (local model hosting) - Cerebras (language models like Llama 4, Qwen 3, and more) - Perplexity (Sonar models, supporting search results) + - Docker Model Runner (local model hosting) * Add comprehensive message system with role-based messaging: - `UserMessage` for user inputs with multi-modal content - `SystemMessage` for system instructions diff --git a/src/platform/src/Bridge/DockerModelRunner/Completions.php b/src/platform/src/Bridge/DockerModelRunner/Completions.php new file mode 100644 index 000000000..2b1cccb22 --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Completions.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Mathieu Santostefano + */ +class Completions extends Model +{ + public const GEMMA_3_N = 'ai/gemma3n'; + public const GEMMA_3 = 'ai/gemma3'; + public const QWEN_2_5 = 'ai/qwen2.5'; + public const QWEN_3 = 'ai/qwen3'; + public const QWEN_3_CODER = 'ai/qwen3-coder'; + public const LLAMA_3_1 = 'ai/llama3.1'; + public const LLAMA_3_2 = 'ai/llama3.2'; + public const LLAMA_3_3 = 'ai/llama3.3'; + public const MISTRAL = 'ai/mistral'; + public const MISTRAL_NEMO = 'ai/mistral-nemo'; + public const PHI_4 = 'ai/phi4'; + public const DEEPSEEK_R_1 = 'ai/deepseek-r1-distill-llama'; + public const SEED_OSS = 'ai/seed-oss'; + public const GPT_OSS = 'ai/gpt-oss'; + public const SMOLLM_2 = 'ai/smollm2'; + public const SMOLLM_3 = 'ai/smollm3'; + + private const TOOL_PATTERNS = [ + '/./' => [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STRUCTURED, + ], + '/^llama\D*3(\D*\d+)/' => [ + Capability::TOOL_CALLING, + ], + '/^qwen\d(\.\d)?(-coder)?$/' => [ + Capability::TOOL_CALLING, + ], + '/^(deepseek|mistral|smollm|seed)/' => [ + Capability::TOOL_CALLING, + ], + ]; + + public function __construct( + string $name = self::SMOLLM_2, + array $options = [], + ) { + $capabilities = []; + + foreach (self::TOOL_PATTERNS as $pattern => $possibleCapabilities) { + if (1 === preg_match($pattern, $name)) { + foreach ($possibleCapabilities as $capability) { + $capabilities[] = $capability; + } + } + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/Completions/ModelClient.php b/src/platform/src/Bridge/DockerModelRunner/Completions/ModelClient.php new file mode 100644 index 000000000..83c05dccc --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Completions/ModelClient.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; + +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $hostUrl, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Completions; + } + + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/engines/v1/chat/completions', $this->hostUrl), [ + 'json' => array_merge($options, $payload), + ])); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php b/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php new file mode 100644 index 000000000..a3c99ed88 --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; + +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter as OpenAiResponseConverter; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\ResultConverterInterface; + +/** + * @author Mathieu Santostefano + */ +final class ResultConverter implements ResultConverterInterface +{ + public function __construct( + private readonly OpenAiResponseConverter $gptResponseConverter = new OpenAiResponseConverter(), + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Completions; + } + + public function convert(RawResultInterface $result, array $options = []): ResultInterface + { + return $this->gptResponseConverter->convert($result, $options); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/Embeddings.php b/src/platform/src/Bridge/DockerModelRunner/Embeddings.php new file mode 100644 index 000000000..4b273002f --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Embeddings.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Mathieu Santostefano + */ +class Embeddings extends Model +{ + public const NOMIC_EMBED_TEXT = 'ai/nomic-embed-text-v1.5'; + public const MXBAI_EMBED_LARGE = 'ai/mxbai-embed-large'; + public const EMBEDDING_GEMMA = 'ai/embeddinggemma'; + public const GRANITE_EMBEDDING_MULTI = 'ai/granite-embedding-multilingual'; + + private const TOOL_PATTERNS = [ + '/./' => [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STRUCTURED, + ], + '/^(nomic).*/' => [ + Capability::INPUT_MULTIPLE, + ], + ]; + + public function __construct( + string $name = self::NOMIC_EMBED_TEXT, + array $options = [], + ) { + $capabilities = []; + + foreach (self::TOOL_PATTERNS as $pattern => $possibleCapabilities) { + if (1 === preg_match($pattern, $name)) { + foreach ($possibleCapabilities as $capability) { + $capabilities[] = $capability; + } + } + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/Embeddings/ModelClient.php b/src/platform/src/Bridge/DockerModelRunner/Embeddings/ModelClient.php new file mode 100644 index 000000000..e2d5ae812 --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Embeddings/ModelClient.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; + +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + */ +final readonly class ModelClient implements ModelClientInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $hostUrl, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/engines/v1/embeddings', $this->hostUrl), [ + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ])); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/Embeddings/ResultConverter.php b/src/platform/src/Bridge/DockerModelRunner/Embeddings/ResultConverter.php new file mode 100644 index 000000000..a83f4a96f --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/Embeddings/ResultConverter.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; + +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\VectorResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Vector\Vector; + +/** + * @author Mathieu Santostefano + */ +final class ResultConverter implements ResultConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function convert(RawResultInterface $result, array $options = []): VectorResult + { + $data = $result->getData(); + + if (!isset($data['data'])) { + throw new RuntimeException('Response does not contain data.'); + } + + return new VectorResult( + ...array_map( + static fn (array $item): Vector => new Vector($item['embedding']), + $data['data'] + ), + ); + } +} diff --git a/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php b/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php new file mode 100644 index 000000000..b234d8cf4 --- /dev/null +++ b/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DockerModelRunner; + +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + */ +class PlatformFactory +{ + public static function create( + string $hostUrl = 'http://localhost:12434', + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [ + new Completions\ModelClient($httpClient, $hostUrl), + new Embeddings\ModelClient($httpClient, $hostUrl), + ], + [ + new Embeddings\ResultConverter(), + new Completions\ResultConverter(), + ], $contract); + } +} diff --git a/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php b/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php new file mode 100644 index 000000000..26c44491d --- /dev/null +++ b/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DockerModelRunner\Completions; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions\ModelClient; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ModelClient::class)] +#[UsesClass(Completions::class)] +#[UsesClass(EventSourceHttpClient::class)] +#[Small] +class ModelClientTest extends TestCase +{ + public function testItIsSupportingTheCorrectModel() + { + $client = new ModelClient(new MockHttpClient(), 'http://localhost:1234'); + + $this->assertTrue($client->supports(new Completions('test-model'))); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('http://localhost:1234/engines/v1/chat/completions', $url); + self::assertSame( + '{"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', + $options['body'] + ); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $payload = [ + 'model' => 'test-model', + 'messages' => [ + ['role' => 'user', 'content' => 'Hello, world!'], + ], + ]; + + $client->request(new Completions('test-model'), $payload); + } + + public function testItMergesOptionsWithPayload() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('http://localhost:1234/engines/v1/chat/completions', $url); + self::assertSame( + '{"temperature":0.7,"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', + $options['body'] + ); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $payload = [ + 'model' => 'test-model', + 'messages' => [ + ['role' => 'user', 'content' => 'Hello, world!'], + ], + ]; + + $client->request(new Completions('test-model'), $payload, ['temperature' => 0.7]); + } + + public function testItUsesEventSourceHttpClient() + { + $httpClient = new MockHttpClient(); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $reflection = new \ReflectionProperty($client, 'httpClient'); + + $this->assertInstanceOf(EventSourceHttpClient::class, $reflection->getValue($client)); + } + + public function testItKeepsExistingEventSourceHttpClient() + { + $eventSourceHttpClient = new EventSourceHttpClient(new MockHttpClient()); + $client = new ModelClient($eventSourceHttpClient, 'http://localhost:1234'); + + $reflection = new \ReflectionProperty($client, 'httpClient'); + + $this->assertSame($eventSourceHttpClient, $reflection->getValue($client)); + } +} diff --git a/src/platform/tests/Bridge/DockerModelRunner/Completions/ResultConverterTest.php b/src/platform/tests/Bridge/DockerModelRunner/Completions/ResultConverterTest.php new file mode 100644 index 000000000..38c9c37e2 --- /dev/null +++ b/src/platform/tests/Bridge/DockerModelRunner/Completions/ResultConverterTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DockerModelRunner\Completions; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions\ResultConverter; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter as OpenAiResultConverter; + +#[CoversClass(ResultConverter::class)] +#[UsesClass(Completions::class)] +#[UsesClass(OpenAiResultConverter::class)] +#[Small] +class ResultConverterTest extends TestCase +{ + public function testItSupportsCompletionsModel() + { + $converter = new ResultConverter(); + + $this->assertTrue($converter->supports(new Completions('test-model'))); + } +} diff --git a/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php new file mode 100644 index 000000000..10a5a994d --- /dev/null +++ b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DockerModelRunner\Embeddings; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings\ModelClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ModelClient::class)] +#[UsesClass(Embeddings::class)] +#[Small] +class ModelClientTest extends TestCase +{ + public function testItIsSupportingTheCorrectModel() + { + $client = new ModelClient(new MockHttpClient(), 'http://localhost:1234'); + + $this->assertTrue($client->supports(new Embeddings('test-model'))); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('http://localhost:1234/engines/v1/embeddings', $url); + self::assertSame('{"model":"test-model","input":"Hello, world!"}', $options['body']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $model = new Embeddings('test-model'); + + $client->request($model, 'Hello, world!'); + } + + public function testItMergesOptionsWithPayload() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('http://localhost:1234/engines/v1/embeddings', $url); + self::assertSame( + '{"custom_option":"value","model":"test-model","input":"Hello, world!"}', + $options['body'] + ); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $model = new Embeddings('test-model'); + + $client->request($model, 'Hello, world!', ['custom_option' => 'value']); + } + + public function testItHandlesArrayInput() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('http://localhost:1234/engines/v1/embeddings', $url); + self::assertSame('{"model":"test-model","input":["Hello","world"]}', $options['body']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'http://localhost:1234'); + + $model = new Embeddings('test-model'); + + $client->request($model, ['Hello', 'world']); + } +} diff --git a/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ResultConverterTest.php b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ResultConverterTest.php new file mode 100644 index 000000000..b294532c5 --- /dev/null +++ b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ResultConverterTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DockerModelRunner\Embeddings; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Embeddings\ResultConverter; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\VectorResult; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(ResultConverter::class)] +#[Small] +#[UsesClass(Vector::class)] +#[UsesClass(VectorResult::class)] +#[UsesClass(Embeddings::class)] +class ResultConverterTest extends TestCase +{ + public function testItConvertsAResponseToAVectorResult() + { + $result = $this->createStub(ResponseInterface::class); + $result + ->method('toArray') + ->willReturn( + json_decode( + <<<'JSON' + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.3, 0.4, 0.4] + }, + { + "object": "embedding", + "index": 1, + "embedding": [0.0, 0.0, 0.2] + } + ] + } + JSON, + true + ) + ); + + $vectorResult = (new ResultConverter())->convert(new RawHttpResult($result)); + $convertedContent = $vectorResult->getContent(); + + $this->assertCount(2, $convertedContent); + + $this->assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData()); + $this->assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData()); + } + + public function testItThrowsExceptionWhenResponseDoesNotContainData() + { + $result = $this->createStub(ResponseInterface::class); + $result + ->method('toArray') + ->willReturn(['invalid' => 'response']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Response does not contain data'); + + (new ResultConverter())->convert(new RawHttpResult($result)); + } + + public function testItSupportsEmbeddingsModel() + { + $converter = new ResultConverter(); + + $this->assertTrue($converter->supports(new Embeddings('test-model'))); + } +} From faa91f47bb5e1f8137f3a97afac62b1d2cf87b6d Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 12 Sep 2025 13:59:32 +0200 Subject: [PATCH 2/4] Add tool calling support and examples for Docker Model Runner platform --- examples/dockermodelrunner/embeddings.php | 2 +- examples/dockermodelrunner/toolcall.php | 34 ++++ .../Bridge/DockerModelRunner/Completions.php | 31 +--- .../Completions/ResultConverter.php | 169 +++++++++++++++++- .../Bridge/DockerModelRunner/Embeddings.php | 25 +-- .../Exception/ExceedContextSizeException.php | 19 ++ .../Completions/ModelClientTest.php | 10 +- .../Embeddings/ModelClientTest.php | 5 +- 8 files changed, 224 insertions(+), 71 deletions(-) create mode 100644 examples/dockermodelrunner/toolcall.php create mode 100644 src/platform/src/Exception/ExceedContextSizeException.php diff --git a/examples/dockermodelrunner/embeddings.php b/examples/dockermodelrunner/embeddings.php index 0c1defcfc..23dce0573 100644 --- a/examples/dockermodelrunner/embeddings.php +++ b/examples/dockermodelrunner/embeddings.php @@ -21,4 +21,4 @@ country was very peaceful and prosperous. The people lived happily ever after. TEXT); -echo 'Dimensions: '.$response->asVectors()[0]->getDimensions().\PHP_EOL; +print_vectors($response); diff --git a/examples/dockermodelrunner/toolcall.php b/examples/dockermodelrunner/toolcall.php new file mode 100644 index 000000000..012cb259f --- /dev/null +++ b/examples/dockermodelrunner/toolcall.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Wikipedia; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; +use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DOCKER_MODEL_RUNNER_HOST_URL'), http_client()); +$model = new Completions(Completions::GEMMA_3_N); + +$wikipedia = new Wikipedia(http_client()); +$toolbox = new Toolbox([$wikipedia]); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); + +$messages = new MessageBag(Message::ofUser('Who is the actual Prime Minister of France?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/platform/src/Bridge/DockerModelRunner/Completions.php b/src/platform/src/Bridge/DockerModelRunner/Completions.php index 2b1cccb22..56f1bcaf5 100644 --- a/src/platform/src/Bridge/DockerModelRunner/Completions.php +++ b/src/platform/src/Bridge/DockerModelRunner/Completions.php @@ -36,37 +36,12 @@ class Completions extends Model public const SMOLLM_2 = 'ai/smollm2'; public const SMOLLM_3 = 'ai/smollm3'; - private const TOOL_PATTERNS = [ - '/./' => [ - Capability::INPUT_MESSAGES, - Capability::OUTPUT_TEXT, - Capability::OUTPUT_STRUCTURED, - ], - '/^llama\D*3(\D*\d+)/' => [ - Capability::TOOL_CALLING, - ], - '/^qwen\d(\.\d)?(-coder)?$/' => [ - Capability::TOOL_CALLING, - ], - '/^(deepseek|mistral|smollm|seed)/' => [ - Capability::TOOL_CALLING, - ], - ]; - public function __construct( string $name = self::SMOLLM_2, array $options = [], ) { - $capabilities = []; - - foreach (self::TOOL_PATTERNS as $pattern => $possibleCapabilities) { - if (1 === preg_match($pattern, $name)) { - foreach ($possibleCapabilities as $capability) { - $capabilities[] = $capability; - } - } - } - - parent::__construct($name, $capabilities, $options); + // All capabilities are assumed to be supported since we cannot know in advance + // whether Docker Model Runner and/or each model allows for a particular capability. + parent::__construct($name, Capability::cases(), $options); } } diff --git a/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php b/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php index a3c99ed88..3aa25c136 100644 --- a/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php +++ b/src/platform/src/Bridge/DockerModelRunner/Completions/ResultConverter.php @@ -12,29 +12,182 @@ namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions; -use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter as OpenAiResponseConverter; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\ExceedContextSizeException; +use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; use Symfony\AI\Platform\Result\RawResultInterface; use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Result\ToolCallResult; use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @author Mathieu Santostefano */ final class ResultConverter implements ResultConverterInterface { - public function __construct( - private readonly OpenAiResponseConverter $gptResponseConverter = new OpenAiResponseConverter(), - ) { - } - public function supports(Model $model): bool { return $model instanceof Completions; } - public function convert(RawResultInterface $result, array $options = []): ResultInterface + public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface + { + if ($options['stream'] ?? false) { + return new StreamResult($this->convertStream($result->getObject())); + } + + $data = $result->getData(); + + if (isset($data['error']['type']) && 'exceed_context_size_error' === $data['error']['type']) { + throw new ExceedContextSizeException($data['error']['message']); + } + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException($data['error']['message']); + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices.'); + } + + $choices = array_map($this->convertChoice(...), $data['choices']); + + return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices); + } + + private function convertStream(HttpResponse $result): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($result) 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 ToolCallResult(...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 { - return $this->gptResponseConverter->convert($result, $options); + 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: int, + * 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): ToolCallResult|TextResult + { + if ('tool_calls' === $choice['finish_reason']) { + return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) { + return new TextResult($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($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); } } diff --git a/src/platform/src/Bridge/DockerModelRunner/Embeddings.php b/src/platform/src/Bridge/DockerModelRunner/Embeddings.php index 4b273002f..eb52be361 100644 --- a/src/platform/src/Bridge/DockerModelRunner/Embeddings.php +++ b/src/platform/src/Bridge/DockerModelRunner/Embeddings.php @@ -24,31 +24,12 @@ class Embeddings extends Model public const EMBEDDING_GEMMA = 'ai/embeddinggemma'; public const GRANITE_EMBEDDING_MULTI = 'ai/granite-embedding-multilingual'; - private const TOOL_PATTERNS = [ - '/./' => [ - Capability::INPUT_MESSAGES, - Capability::OUTPUT_TEXT, - Capability::OUTPUT_STRUCTURED, - ], - '/^(nomic).*/' => [ - Capability::INPUT_MULTIPLE, - ], - ]; - public function __construct( string $name = self::NOMIC_EMBED_TEXT, array $options = [], ) { - $capabilities = []; - - foreach (self::TOOL_PATTERNS as $pattern => $possibleCapabilities) { - if (1 === preg_match($pattern, $name)) { - foreach ($possibleCapabilities as $capability) { - $capabilities[] = $capability; - } - } - } - - parent::__construct($name, $capabilities, $options); + // All capabilities are assumed to be supported since we cannot know in advance + // whether Docker Model Runner and/or each model allows for a particular capability. + parent::__construct($name, Capability::cases(), $options); } } diff --git a/src/platform/src/Exception/ExceedContextSizeException.php b/src/platform/src/Exception/ExceedContextSizeException.php new file mode 100644 index 000000000..80827ae27 --- /dev/null +++ b/src/platform/src/Exception/ExceedContextSizeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Mathieu Santostefano + */ +class ExceedContextSizeException extends InvalidArgumentException +{ +} diff --git a/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php b/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php index 26c44491d..9e9366785 100644 --- a/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php +++ b/src/platform/tests/Bridge/DockerModelRunner/Completions/ModelClientTest.php @@ -39,10 +39,7 @@ public function testItIsExecutingTheCorrectRequest() $resultCallback = static function (string $method, string $url, array $options): MockResponse { self::assertSame('POST', $method); self::assertSame('http://localhost:1234/engines/v1/chat/completions', $url); - self::assertSame( - '{"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', - $options['body'] - ); + self::assertSame('{"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', $options['body']); return new MockResponse(); }; @@ -65,10 +62,7 @@ public function testItMergesOptionsWithPayload() $resultCallback = static function (string $method, string $url, array $options): MockResponse { self::assertSame('POST', $method); self::assertSame('http://localhost:1234/engines/v1/chat/completions', $url); - self::assertSame( - '{"temperature":0.7,"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', - $options['body'] - ); + self::assertSame('{"temperature":0.7,"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}', $options['body']); return new MockResponse(); }; diff --git a/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php index 10a5a994d..17f753c49 100644 --- a/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php +++ b/src/platform/tests/Bridge/DockerModelRunner/Embeddings/ModelClientTest.php @@ -55,10 +55,7 @@ public function testItMergesOptionsWithPayload() $resultCallback = static function (string $method, string $url, array $options): MockResponse { self::assertSame('POST', $method); self::assertSame('http://localhost:1234/engines/v1/embeddings', $url); - self::assertSame( - '{"custom_option":"value","model":"test-model","input":"Hello, world!"}', - $options['body'] - ); + self::assertSame('{"custom_option":"value","model":"test-model","input":"Hello, world!"}', $options['body']); return new MockResponse(); }; From 93464f6a3e6e4db4520bf155852cd4525402e63a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 12 Sep 2025 14:24:41 +0200 Subject: [PATCH 3/4] Fix --- .github/build-packages.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/build-packages.php b/.github/build-packages.php index aea55f365..262c6f101 100644 --- a/.github/build-packages.php +++ b/.github/build-packages.php @@ -9,7 +9,7 @@ use Symfony\Component\Finder\Finder; $finder = (new Finder()) - ->in([__DIR__.'/../src/*/']) + ->in([__DIR__.'/../src/*/', __DIR__.'/../examples/']) ->depth(0) ->name('composer.json') ; From 399bc96c580ff5743ff4ecc92d4fb62aea1f7560 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 12 Sep 2025 15:55:50 +0200 Subject: [PATCH 4/4] Try 2 --- .github/build-packages.php | 2 +- .github/workflows/code-quality.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/build-packages.php b/.github/build-packages.php index 262c6f101..aea55f365 100644 --- a/.github/build-packages.php +++ b/.github/build-packages.php @@ -9,7 +9,7 @@ use Symfony\Component\Finder\Finder; $finder = (new Finder()) - ->in([__DIR__.'/../src/*/', __DIR__.'/../examples/']) + ->in([__DIR__.'/../src/*/']) ->depth(0) ->name('composer.json') ; diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 9e7be91ec..42ff9a0c4 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -60,12 +60,12 @@ jobs: - name: Build root packages run: php .github/build-packages.php + - name: Run PHPStan on examples + run: | + cd examples/ && $COMPOSER_UP && ../link && $PHPSTAN + - name: Run PHPStan on packages run: | source .github/workflows/.utils.sh echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} '(cd src/{} && $COMPOSER_UP && $PHPSTAN)'" - - - name: Run PHPStan on examples - run: | - cd examples/ && $COMPOSER_UP && $PHPSTAN