From bd91b18ede19926f2bb0e45e3ee23434bfcf46fb Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 22 Oct 2025 18:14:44 +0200 Subject: [PATCH] [Platform] Support multimodal embeddings with Voyage AI --- docs/components/platform/voyage.rst | 65 ++++++++++ examples/voyage/multimodal-embeddings.php | 37 ++++++ ...dings.php => multiple-text-embeddings.php} | 0 .../{embeddings.php => text-embeddings.php} | 0 src/platform/CHANGELOG.md | 1 + .../Multimodal/CollectionNormalizer.php | 54 ++++++++ .../Contract/Multimodal/ImageNormalizer.php | 50 ++++++++ .../Multimodal/ImageUrlNormalizer.php | 44 +++++++ .../Multimodal/MultimodalNormalizer.php | 60 +++++++++ .../Contract/Multimodal/TextNormalizer.php | 44 +++++++ .../Bridge/Voyage/Contract/VoyageContract.php | 35 ++++++ .../src/Bridge/Voyage/ModelCatalog.php | 7 ++ .../src/Bridge/Voyage/ModelClient.php | 21 +++- .../src/Bridge/Voyage/PlatformFactory.php | 3 +- src/platform/src/Capability.php | 1 + .../src/Message/Content/Collection.php | 33 +++++ .../Multimodal/CollectionNormalizerTest.php | 117 ++++++++++++++++++ .../Multimodal/ImageNormalizerTest.php | 67 ++++++++++ .../Multimodal/ImageUrlNormalizerTest.php | 62 ++++++++++ .../Multimodal/MultimodalNormalizerTest.php | 112 +++++++++++++++++ .../Multimodal/TextNormalizerTest.php | 61 +++++++++ .../Voyage/Contract/VoyageContractTest.php | 106 ++++++++++++++++ .../tests/Bridge/Voyage/ModelCatalogTest.php | 1 + .../tests/Bridge/Voyage/ModelClientTest.php | 78 ++++++++++++ 24 files changed, 1054 insertions(+), 5 deletions(-) create mode 100644 docs/components/platform/voyage.rst create mode 100644 examples/voyage/multimodal-embeddings.php rename examples/voyage/{multiple-embeddings.php => multiple-text-embeddings.php} (100%) rename examples/voyage/{embeddings.php => text-embeddings.php} (100%) create mode 100644 src/platform/src/Bridge/Voyage/Contract/Multimodal/CollectionNormalizer.php create mode 100644 src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageNormalizer.php create mode 100644 src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizer.php create mode 100644 src/platform/src/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizer.php create mode 100644 src/platform/src/Bridge/Voyage/Contract/Multimodal/TextNormalizer.php create mode 100644 src/platform/src/Bridge/Voyage/Contract/VoyageContract.php create mode 100644 src/platform/src/Message/Content/Collection.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/Multimodal/CollectionNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/Multimodal/TextNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Voyage/Contract/VoyageContractTest.php create mode 100644 src/platform/tests/Bridge/Voyage/ModelClientTest.php diff --git a/docs/components/platform/voyage.rst b/docs/components/platform/voyage.rst new file mode 100644 index 000000000..3b424895a --- /dev/null +++ b/docs/components/platform/voyage.rst @@ -0,0 +1,65 @@ +Voyage AI +========= + +Voyage AI offers a number of models for embedding text, contextualized chunks, interleaved multimodal data, and reranking. +The bundle currently supports text embedding and multimodal embedding. + +For comprehensive information about Voyage AI, see the `Voyage AI API reference`_ + +Setup +----- + +Authentication +~~~~~~~~~~~~~~ + +Voyage AI requires an API key, which you can set up in `Voyage AI dashboard`_. + +Usage +----- + +Basic text embedding usage example:: + + use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory; + + $platform = PlatformFactory::create($_ENV['VOYAGE_API_KEY'], $httpClient); + + $result = $platform->invoke('voyage-3', <<getContent(); + +Voyage AI supports text, base64 image data, and image URLs in its multimodal embedding model. It also allows for +multiple data types per vector embedding. To do this, wrap the data in a `Collection` as shown in the example below. + +Basic multimodal embedding usage example:: + + use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory; + use Symfony\AI\Platform\Message\Content\Collection; + use Symfony\AI\Platform\Message\Content\ImageUrl; + use Symfony\AI\Platform\Message\Content\Text; + + $platform = PlatformFactory::create($_ENV['VOYAGE_API_KEY'], $httpClient); + + $result = $platform->invoke( + 'voyage-multimodal-3', + new ImageUrl('https://example.com/image1.jpg'), + new Collection(new Text('Hello, world!'), new ImageUrl('https://example.com/image2.jpg') + ); + + echo $result->getContent(); + + +Examples +-------- + +See the ``examples/voyage/`` directory for complete working examples: + +* ``text-embeddings.php`` - Basic text embedding example +* ``multiple-text-embeddings.php`` - Embedding multiple text values +* ``multimodal-embeddings.php`` - Embedding multimodal data (single and multiple values) + +.. _Voyage AI API reference: https://docs.voyageai.com/reference/embeddings-api +.. _Voyage AI dashboard: https://dashboard.voyageai.com/organization/api-keys diff --git a/examples/voyage/multimodal-embeddings.php b/examples/voyage/multimodal-embeddings.php new file mode 100644 index 000000000..64d6d62c3 --- /dev/null +++ b/examples/voyage/multimodal-embeddings.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Collection; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VOYAGE_API_KEY'), http_client()); + +$image = Image::fromFile(dirname(__DIR__, 2).'/fixtures/image.jpg'); + +// Single value +$result1 = $platform->invoke('voyage-multimodal-3', + new Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'), +); + +// Multiple values +$result2 = $platform->invoke('voyage-multimodal-3', [ + new Collection(new Text('Photo of a sunrise'), $image), + new Collection(new Text('Photo of a sunset'), $image), +]); + +echo 'Dimensions for text: '.$result1->asVectors()[0]->getDimensions().\PHP_EOL; + +echo 'Dimensions for sunrise image and description: '.$result2->asVectors()[0]->getDimensions().\PHP_EOL; +echo 'Dimensions for sunset image and description: '.$result2->asVectors()[1]->getDimensions().\PHP_EOL; diff --git a/examples/voyage/multiple-embeddings.php b/examples/voyage/multiple-text-embeddings.php similarity index 100% rename from examples/voyage/multiple-embeddings.php rename to examples/voyage/multiple-text-embeddings.php diff --git a/examples/voyage/embeddings.php b/examples/voyage/text-embeddings.php similarity index 100% rename from examples/voyage/embeddings.php rename to examples/voyage/text-embeddings.php diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 09b45f933..1fc9c985f 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -66,3 +66,4 @@ CHANGELOG * Add tool calling support for Ollama platform * Allow beta feature flags to be passed into Anthropic model options * Add Ollama streaming output support + * Add multimodal embedding support for Voyage AI diff --git a/src/platform/src/Bridge/Voyage/Contract/Multimodal/CollectionNormalizer.php b/src/platform/src/Bridge/Voyage/Contract/Multimodal/CollectionNormalizer.php new file mode 100644 index 000000000..843ae3cda --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/Multimodal/CollectionNormalizer.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\Voyage\Contract\Multimodal; + +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Collection; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +final class CollectionNormalizer extends Contract\Normalizer\ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public const KEY_CONTENT = 'content'; + + /** + * @param Collection $data + * + * @throws ExceptionInterface + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $content = []; + foreach ($data->getContent() as $item) { + $normalized = $this->normalizer->normalize($item, $format, $context); + $content = array_merge($content, array_pop($normalized)[self::KEY_CONTENT]); + } + + return [['content' => $content]]; + } + + protected function supportedDataClass(): string + { + return Collection::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Voyage && $model->supports(Capability::INPUT_MULTIMODAL); + } +} diff --git a/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageNormalizer.php b/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageNormalizer.php new file mode 100644 index 000000000..c705aa6c0 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageNormalizer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal; + +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Model; + +use function Symfony\Component\String\u; + +final class ImageNormalizer extends ModelContractNormalizer +{ + /** + * @param Image $data + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [[ + CollectionNormalizer::KEY_CONTENT => [[ + 'type' => 'image_base64', + 'image_base64' => \sprintf( + 'data:%s;base64,%s', + u($data->getFormat())->replace('jpg', 'jpeg'), + $data->asBase64() + ), + ]], + ]]; + } + + protected function supportedDataClass(): string + { + return Image::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Voyage && $model->supports(Capability::INPUT_MULTIMODAL); + } +} diff --git a/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizer.php b/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizer.php new file mode 100644 index 000000000..4c095df72 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal; + +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Model; + +final class ImageUrlNormalizer extends ModelContractNormalizer +{ + /** + * @param ImageUrl $data + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [[ + CollectionNormalizer::KEY_CONTENT => [[ + 'type' => 'image_url', + 'image_url' => $data->getUrl(), + ]], + ]]; + } + + protected function supportedDataClass(): string + { + return ImageUrl::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Voyage && $model->supports(Capability::INPUT_MULTIMODAL); + } +} diff --git a/src/platform/src/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizer.php b/src/platform/src/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizer.php new file mode 100644 index 000000000..055c0c026 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal; + +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class MultimodalNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + /** + * @param ContentInterface[] $data + * + * @throws ExceptionInterface + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return array_map( + function (ContentInterface $item) use ($format, $context) { + $normalized = $this->normalizer->normalize($item, $format, $context); + + return array_pop($normalized); + }, + $data + ); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + $model = $context[Contract::CONTEXT_MODEL] ?? null; + if (!$model instanceof Voyage || !$model->supports(Capability::INPUT_MULTIMODAL)) { + return false; + } + + return \is_array($data) && [] === array_filter($data, fn ($item) => !$item instanceof ContentInterface); + } + + public function getSupportedTypes(?string $format): array + { + return [ + 'native-array' => true, + ]; + } +} diff --git a/src/platform/src/Bridge/Voyage/Contract/Multimodal/TextNormalizer.php b/src/platform/src/Bridge/Voyage/Contract/Multimodal/TextNormalizer.php new file mode 100644 index 000000000..ce25274d7 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/Multimodal/TextNormalizer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal; + +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Model; + +final class TextNormalizer extends ModelContractNormalizer +{ + /** + * @param Text $data + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [[ + CollectionNormalizer::KEY_CONTENT => [[ + 'type' => 'text', + 'text' => $data->getText(), + ]], + ]]; + } + + protected function supportedDataClass(): string + { + return Text::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Voyage && $model->supports(Capability::INPUT_MULTIMODAL); + } +} diff --git a/src/platform/src/Bridge/Voyage/Contract/VoyageContract.php b/src/platform/src/Bridge/Voyage/Contract/VoyageContract.php new file mode 100644 index 000000000..4919d7c89 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Contract/VoyageContract.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage\Contract; + +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\ImageNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\ImageUrlNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\MultimodalNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\TextNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final readonly class VoyageContract extends Contract +{ + public static function create(NormalizerInterface ...$normalizer): Contract + { + return parent::create( + new MultimodalNormalizer(), + new CollectionNormalizer(), + new TextNormalizer(), + new ImageNormalizer(), + new ImageUrlNormalizer(), + ...$normalizer + ); + } +} diff --git a/src/platform/src/Bridge/Voyage/ModelCatalog.php b/src/platform/src/Bridge/Voyage/ModelCatalog.php index ed31fa908..860e547d4 100644 --- a/src/platform/src/Bridge/Voyage/ModelCatalog.php +++ b/src/platform/src/Bridge/Voyage/ModelCatalog.php @@ -62,6 +62,13 @@ public function __construct(array $additionalModels = []) 'class' => Voyage::class, 'capabilities' => [Capability::INPUT_MULTIPLE], ], + 'voyage-multimodal-3' => [ + 'class' => Voyage::class, + 'capabilities' => [ + Capability::INPUT_MULTIPLE, + Capability::INPUT_MULTIMODAL, + ], + ], ]; $this->models = array_merge($defaultModels, $additionalModels); diff --git a/src/platform/src/Bridge/Voyage/ModelClient.php b/src/platform/src/Bridge/Voyage/ModelClient.php index 2d80d476c..99bacca41 100644 --- a/src/platform/src/Bridge/Voyage/ModelClient.php +++ b/src/platform/src/Bridge/Voyage/ModelClient.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Voyage; +use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; use Symfony\AI\Platform\Result\RawHttpResult; @@ -34,15 +35,27 @@ public function supports(Model $model): bool public function request(Model $model, object|string|array $payload, array $options = []): RawHttpResult { - return new RawHttpResult($this->httpClient->request('POST', 'https://api.voyageai.com/v1/embeddings', [ + [$inputKey, $endpoint] = $model->supports(Capability::INPUT_MULTIMODAL) + ? ['inputs', 'multimodalembeddings'] + : ['input', 'embeddings']; + + $body = [ 'auth_bearer' => $this->apiKey, 'json' => [ 'model' => $model->getName(), - 'input' => $payload, + $inputKey => $payload, 'input_type' => $options['input_type'] ?? null, 'truncation' => $options['truncation'] ?? true, - 'output_dimension' => $options['dimensions'] ?? null, ], - ])); + ]; + + if ($model->supports(Capability::INPUT_MULTIMODAL)) { + $body['json']['output_encoding'] = $options['encoding'] ?? null; + } else { + $body['json']['output_dimension'] = $options['dimensions'] ?? null; + $body['json']['encoding_format'] = $options['encoding'] ?? null; + } + + return new RawHttpResult($this->httpClient->request('POST', \sprintf('https://api.voyageai.com/v1/%s', $endpoint), $body)); } } diff --git a/src/platform/src/Bridge/Voyage/PlatformFactory.php b/src/platform/src/Bridge/Voyage/PlatformFactory.php index d26073560..4e65b58eb 100644 --- a/src/platform/src/Bridge/Voyage/PlatformFactory.php +++ b/src/platform/src/Bridge/Voyage/PlatformFactory.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Bridge\Voyage; use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\AI\Platform\Bridge\Voyage\Contract\VoyageContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -36,7 +37,7 @@ public static function create( [new ModelClient($httpClient, $apiKey)], [new ResultConverter()], $modelCatalog, - $contract, + $contract ?? VoyageContract::create(), $eventDispatcher, ); } diff --git a/src/platform/src/Capability.php b/src/platform/src/Capability.php index 7d65c2caf..459c5d0ad 100644 --- a/src/platform/src/Capability.php +++ b/src/platform/src/Capability.php @@ -27,6 +27,7 @@ enum Capability: string case INPUT_MULTIPLE = 'input-multiple'; case INPUT_PDF = 'input-pdf'; case INPUT_TEXT = 'input-text'; + case INPUT_MULTIMODAL = 'input-multimodal'; // OUTPUT case OUTPUT_AUDIO = 'output-audio'; diff --git a/src/platform/src/Message/Content/Collection.php b/src/platform/src/Message/Content/Collection.php new file mode 100644 index 000000000..e0316dd88 --- /dev/null +++ b/src/platform/src/Message/Content/Collection.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +final readonly class Collection implements ContentInterface +{ + /** + * @var ContentInterface[] + */ + private array $content; + + public function __construct(ContentInterface ...$content) + { + $this->content = $content; + } + + /** + * @return ContentInterface[] + */ + public function getContent(): array + { + return $this->content; + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/Multimodal/CollectionNormalizerTest.php b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/CollectionNormalizerTest.php new file mode 100644 index 000000000..0a8e057bd --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/CollectionNormalizerTest.php @@ -0,0 +1,117 @@ + + * + * 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\Voyage\Contract\Multimodal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\ImageUrlNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\TextNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Collection; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\Component\Serializer\Serializer; + +class CollectionNormalizerTest extends TestCase +{ + #[DataProvider('normalizeProvider')] + public function testNormalize(mixed $data, array $expected) + { + $serializer = new Serializer( + [ + new CollectionNormalizer(), + new TextNormalizer(), + new ImageUrlNormalizer(), + ], + ); + + $actual = $serializer->normalize( + $data, + context: [ + Contract::CONTEXT_MODEL => new Voyage('some-model', [Capability::INPUT_MULTIMODAL]), + ], + ); + + $this->assertEquals($expected, $actual); + } + + #[DataProvider('supportsNormalizationProvider')] + public function testSupportsNormalization(mixed $data, array $context, bool $expected) + { + $normalizer = new CollectionNormalizer(); + $this->assertEquals( + $expected, + $normalizer->supportsNormalization($data, context: $context) + ); + } + + public static function normalizeProvider(): \Generator + { + $text = new Text('Lorem ipsum'); + $imageUrl = new ImageUrl('https://example.com/image.jpg'); + + yield 'single value' => [ + new Collection($text), + [ + ['content' => [ + ['type' => 'text', 'text' => $text->getText()], + ]], + ], + ]; + + yield 'multiple values' => [ + new Collection($text, $imageUrl), + [ + ['content' => [ + ['type' => 'text', 'text' => $text->getText()], + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ]], + ], + ]; + } + + public static function supportsNormalizationProvider(): \Generator + { + yield 'supported object' => [ + new Collection(), + [ + Contract::CONTEXT_MODEL => new Voyage('some-model', [Capability::INPUT_MULTIMODAL]), + ], + true, + ]; + yield 'unsupported model' => [ + new Collection(), + [ + Contract::CONTEXT_MODEL => new Gpt('some-model', [Capability::INPUT_MULTIMODAL]), + ], + false, + ]; + yield 'model lacks multimodal capability' => [ + new Collection(), + [ + Contract::CONTEXT_MODEL => new Voyage('some-model'), + ], + false, + ]; + yield 'unsupported data' => [ + 'Foo', + [ + Contract::CONTEXT_MODEL => new Voyage('some-model', [Capability::INPUT_MULTIMODAL]), + ], + false, + ]; + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageNormalizerTest.php b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageNormalizerTest.php new file mode 100644 index 000000000..3e9beab49 --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageNormalizerTest.php @@ -0,0 +1,67 @@ + + * + * 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\Voyage\Contract\Multimodal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\ImageNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Model; + +final class ImageNormalizerTest extends TestCase +{ + public function testNormalize() + { + $image = $this->getFixtureImage(); + + $normalizer = new ImageNormalizer(); + $normalized = $normalizer->normalize($image); + + $this->assertEquals([[ + CollectionNormalizer::KEY_CONTENT => [ + [ + 'type' => 'image_base64', + 'image_base64' => 'data:image/jpeg;base64,'.$image->asBase64(), + ], + ], + ]], $normalized); + } + + #[DataProvider('supportsNormalizationDataProvider')] + public function testSupportsNormalization(mixed $data, Model $model, bool $result) + { + $normalizer = new ImageNormalizer(); + $this->assertEquals($result, $normalizer->supportsNormalization($data, context: [ + Contract::CONTEXT_MODEL => $model, + ])); + } + + public static function supportsNormalizationDataProvider(): \Generator + { + $image = self::getFixtureImage(); + + yield 'supported' => [$image, new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), true]; + yield 'not an image' => [[], new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), false]; + yield 'non-multimodal model' => [$image, new Voyage('voyage-3.5'), false]; + yield 'unsupported model' => [$image, new Gpt('gpt-40'), false]; + } + + private static function getFixtureImage(): Image + { + return Image::fromFile(\dirname(__DIR__, 7).'/fixtures/image.jpg'); + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizerTest.php b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizerTest.php new file mode 100644 index 000000000..ff7416a8b --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/ImageUrlNormalizerTest.php @@ -0,0 +1,62 @@ + + * + * 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\Voyage\Contract\Multimodal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\ImageUrlNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Model; + +final class ImageUrlNormalizerTest extends TestCase +{ + public function testNormalize() + { + $urlStr = 'https://example.org/foo.jpg'; + $url = new ImageUrl($urlStr); + + $normalizer = new ImageUrlNormalizer(); + $normalized = $normalizer->normalize($url); + + $this->assertEquals([[ + CollectionNormalizer::KEY_CONTENT => [ + [ + 'type' => 'image_url', + 'image_url' => $urlStr, + ]], + ]], $normalized); + } + + #[DataProvider('supportsNormalizationDataProvider')] + public function testSupportsNormalization(mixed $data, Model $model, bool $result) + { + $normalizer = new ImageUrlNormalizer(); + $this->assertEquals($result, $normalizer->supportsNormalization($data, context: [ + Contract::CONTEXT_MODEL => $model, + ])); + } + + public static function supportsNormalizationDataProvider(): \Generator + { + $url = new ImageUrl('https://example.org/foo.jpg'); + + yield 'supported' => [$url, new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), true]; + yield 'not an image' => [[], new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), false]; + yield 'non-multimodal model' => [$url, new Voyage('voyage-3.5'), false]; + yield 'unsupported model' => [$url, new Gpt('gpt-40'), false]; + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizerTest.php b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizerTest.php new file mode 100644 index 000000000..067d80b6b --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/MultimodalNormalizerTest.php @@ -0,0 +1,112 @@ + + * + * 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\Voyage\Contract\Multimodal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\MultimodalNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\TextNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Collection; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Serializer; + +class MultimodalNormalizerTest extends TestCase +{ + public function testNormalize() + { + $text = new Text('Foo'); + + $serializer = new Serializer( + [ + new MultimodalNormalizer(), + new CollectionNormalizer(), + new TextNormalizer(), + ], + ); + + $this->assertEquals( + [ + [ + 'content' => [['type' => 'text', 'text' => 'Foo']], + ], + [ + 'content' => [ + ['type' => 'text', 'text' => 'Foo'], + ['type' => 'text', 'text' => 'Foo'], + ], + ], + ], + $serializer->normalize([ + $text, + new Collection($text, $text), + ], context: [Contract::CONTEXT_MODEL => new Voyage('some-model', [Capability::INPUT_MULTIMODAL])]) + ); + } + + #[DataProvider('supportsNormalizationProvider')] + public function testSupportsNormalization(mixed $input, Model $model, bool $expected) + { + $normalizer = new MultimodalNormalizer(); + $this->assertEquals( + $expected, + $normalizer->supportsNormalization($input, context: [Contract::CONTEXT_MODEL => $model]) + ); + } + + public static function supportsNormalizationProvider(): \Generator + { + $text = new Text('Foo'); + $model = new Voyage('some-model', [Capability::INPUT_MULTIMODAL]); + + yield 'array of supported objects' => [ + [ + $text, + new Collection($text), + ], + $model, + true, + ]; + + yield 'array of unsupported data' => [ + [ + $text, + 'Foo', + ], + $model, + false, + ]; + + yield 'not an array' => [ + 'Foo', + $model, + false, + ]; + + yield 'unsupported model' => [ + [$text], + new Gpt('some-model', [Capability::INPUT_MULTIMODAL]), + false, + ]; + + yield 'non-multimodal model' => [ + [$text], + new Voyage('some-model', []), + false, + ]; + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/Multimodal/TextNormalizerTest.php b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/TextNormalizerTest.php new file mode 100644 index 000000000..3efce10f7 --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/Multimodal/TextNormalizerTest.php @@ -0,0 +1,61 @@ + + * + * 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\Voyage\Contract\Multimodal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\CollectionNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Contract\Multimodal\TextNormalizer; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Model; + +final class TextNormalizerTest extends TestCase +{ + public function testNormalize() + { + $text = 'Symfony rules'; + + $normalizer = new TextNormalizer(); + $normalized = $normalizer->normalize(new Text($text)); + + $this->assertEquals([[ + CollectionNormalizer::KEY_CONTENT => [ + [ + 'type' => 'text', + 'text' => $text, + ]], + ]], $normalized); + } + + #[DataProvider('supportsNormalizationDataProvider')] + public function testSupportsNormalization(mixed $data, Model $model, bool $result) + { + $normalizer = new TextNormalizer(); + $this->assertEquals($result, $normalizer->supportsNormalization($data, context: [ + Contract::CONTEXT_MODEL => $model, + ])); + } + + public static function supportsNormalizationDataProvider(): \Generator + { + $text = new Text('Symfony rules'); + + yield 'supported' => [$text, new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), true]; + yield 'not text' => [[], new Voyage('voyage-multimodal-3', [Capability::INPUT_MULTIMODAL]), false]; + yield 'non-multimodal model' => [$text, new Voyage('voyage-3.5'), false]; + yield 'unsupported model' => [$text, new Gpt('gpt-40'), false]; + } +} diff --git a/src/platform/tests/Bridge/Voyage/Contract/VoyageContractTest.php b/src/platform/tests/Bridge/Voyage/Contract/VoyageContractTest.php new file mode 100644 index 000000000..4575a9a7a --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/Contract/VoyageContractTest.php @@ -0,0 +1,106 @@ + + * + * 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\Voyage\Contract; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Voyage\Contract\VoyageContract; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\Content\Collection; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; + +final class VoyageContractTest extends TestCase +{ + #[DataProvider('createRequestPayloadProvider')] + public function testCreateMultimodalRequestPayload(array|ContentInterface $input, array $expected) + { + $contract = VoyageContract::create(); + + $this->assertEquals($expected, $contract->createRequestPayload( + new Voyage('some-model', [Capability::INPUT_MULTIMODAL]), + $input + )); + } + + public static function createRequestPayloadProvider(): \Generator + { + $text = new Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + $imageUrl = new ImageUrl('https://example.com/image.jpg'); + + yield 'single content value' => [ + $text, + [ + [ + 'content' => [ + ['type' => 'text', 'text' => $text->getText()], + ], + ], + ], + ]; + + yield 'single multimodal value' => [ + new Collection($text, $imageUrl), + [ + [ + 'content' => [ + ['type' => 'text', 'text' => $text->getText()], + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ], + ], + ], + ]; + + yield 'multiple multimodal values' => [ + [ + new Collection($text, $imageUrl), + new Collection($text, $imageUrl), + ], + [ + [ + 'content' => [ + ['type' => 'text', 'text' => $text->getText()], + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ], + ], + [ + 'content' => [ + ['type' => 'text', 'text' => $text->getText()], + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ], + ], + ], + ]; + + yield 'multiple mixed content and multimodal values' => [ + [ + $imageUrl, + new Collection($text, $imageUrl), + ], + [ + [ + 'content' => [ + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ], + ], + [ + 'content' => [ + ['type' => 'text', 'text' => $text->getText()], + ['type' => 'image_url', 'image_url' => $imageUrl->getUrl()], + ], + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/Voyage/ModelCatalogTest.php b/src/platform/tests/Bridge/Voyage/ModelCatalogTest.php index af2e50f8e..171fcd4c6 100644 --- a/src/platform/tests/Bridge/Voyage/ModelCatalogTest.php +++ b/src/platform/tests/Bridge/Voyage/ModelCatalogTest.php @@ -31,6 +31,7 @@ public static function modelsProvider(): iterable yield 'voyage-law-2' => ['voyage-law-2', Voyage::class, [Capability::INPUT_MULTIPLE]]; yield 'voyage-code-3' => ['voyage-code-3', Voyage::class, [Capability::INPUT_MULTIPLE]]; yield 'voyage-code-2' => ['voyage-code-2', Voyage::class, [Capability::INPUT_MULTIPLE]]; + yield 'voyage-multimodal-3' => ['voyage-multimodal-3', Voyage::class, [Capability::INPUT_MULTIPLE, Capability::INPUT_MULTIMODAL]]; } protected function createModelCatalog(): ModelCatalogInterface diff --git a/src/platform/tests/Bridge/Voyage/ModelClientTest.php b/src/platform/tests/Bridge/Voyage/ModelClientTest.php new file mode 100644 index 000000000..62b89a0e4 --- /dev/null +++ b/src/platform/tests/Bridge/Voyage/ModelClientTest.php @@ -0,0 +1,78 @@ + + * + * 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\Voyage; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Voyage\ModelClient; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\Capability; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +final class ModelClientTest extends TestCase +{ + #[DataProvider('requestProvider')] + public function testItSendsExpectedRequest(Voyage $model, string $expectedPath, array $expectedPayload) + { + $resultCallback = static function ( + string $method, + string $url, + array $options, + ) use ($expectedPath, $expectedPayload): MockResponse { + self::assertSame('POST', $method); + self::assertSame(\sprintf('https://api.voyageai.com/v1/%s', $expectedPath), $url); + self::assertSame(json_encode($expectedPayload), $options['body']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, ''); + + $client->request($model, 'Hello, world!', [ + 'dimensions' => 300, + ]); + } + + public static function requestProvider(): \Generator + { + $textEmbeddingModel = new Voyage('some-text-embedding-model', []); + $multimodalEmbeddingModel = new Voyage('some-multimodal-embedding-model', [Capability::INPUT_MULTIMODAL]); + $input = 'Hello, world!'; + + yield 'for text embedding' => [ + $textEmbeddingModel, + 'embeddings', + [ + 'model' => $textEmbeddingModel->getName(), + 'input' => $input, + 'input_type' => null, + 'truncation' => true, + 'output_dimension' => 300, + 'encoding_format' => null, + ], + ]; + + yield 'for multimodal embedding' => [ + $multimodalEmbeddingModel, + 'multimodalembeddings', + [ + 'model' => $multimodalEmbeddingModel->getName(), + 'inputs' => $input, + 'input_type' => null, + 'truncation' => true, + 'output_encoding' => null, + ], + ]; + } +}