diff --git a/examples/.env b/examples/.env index ef0a3c7ae5..e8ad4f77f1 100644 --- a/examples/.env +++ b/examples/.env @@ -210,3 +210,6 @@ OVH_AI_SECRET_KEY= # amazee.ai AMAZEEAI_LLM_KEY= AMAZEEAI_LLM_API_URL= + +# For using Venice +VENICE_API_KEY= diff --git a/examples/composer.json b/examples/composer.json index e3e8662339..c6608e4c0e 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -71,6 +71,7 @@ "symfony/ai-transformers-php-platform": "^0.5", "symfony/ai-typesense-store": "^0.5", "symfony/ai-vektor-store": "^0.6", + "symfony/ai-venice-platform": "^0.6", "symfony/ai-vertex-ai-platform": "^0.5", "symfony/ai-voyage-platform": "^0.5", "symfony/ai-weaviate-store": "^0.5", diff --git a/examples/venice/README.md b/examples/venice/README.md new file mode 100644 index 0000000000..e85da503e3 --- /dev/null +++ b/examples/venice/README.md @@ -0,0 +1,9 @@ +# Venice Examples + +One use case of Venice is to convert text to speech, which creates audio files from text input. + +To run the examples, you can use additional tools like (mpg123)[https://www.mpg123.de/]: + +```bash +php venice/text-to-speech.php | mpg123 - +``` diff --git a/examples/venice/chat-as-stream.php b/examples/venice/chat-as-stream.php new file mode 100644 index 0000000000..33eb2ce403 --- /dev/null +++ b/examples/venice/chat-as-stream.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Venice\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VENICE_API_KEY'), httpClient: http_client()); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +); + +$result = $platform->invoke('venice-uncensored', $messages, [ + 'stream' => true, +]); + +foreach ($result->asStream() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/venice/chat.php b/examples/venice/chat.php new file mode 100644 index 0000000000..0779c51d5e --- /dev/null +++ b/examples/venice/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\Platform\Bridge\Venice\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VENICE_API_KEY'), httpClient: http_client()); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +); + +try { + $result = $platform->invoke('venice-uncensored', $messages); + + echo $result->asText().\PHP_EOL; +} catch (InvalidArgumentException $e) { + echo $e->getMessage()."\nMaybe use a different model?\n"; +} diff --git a/examples/venice/embeddings.php b/examples/venice/embeddings.php new file mode 100644 index 0000000000..e9764ab200 --- /dev/null +++ b/examples/venice/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\Venice\PlatformFactory; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VENICE_API_KEY'), httpClient: http_client()); + +$response = $platform->invoke('text-embedding-bge-m3', <<asVectors()[0]->getDimensions().\PHP_EOL; diff --git a/examples/venice/image-generation.php b/examples/venice/image-generation.php new file mode 100644 index 0000000000..a8d5aa6cfc --- /dev/null +++ b/examples/venice/image-generation.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\Venice\PlatformFactory; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VENICE_API_KEY'), httpClient: http_client()); + +try { + $result = $platform->invoke('venice-uncensored', 'A beautiful sunset over a mountain range'); + + echo $result->asText().\PHP_EOL; +} catch (InvalidArgumentException $e) { + echo $e->getMessage()."\nMaybe use a different model?\n"; +} diff --git a/examples/venice/text-to-speech.php b/examples/venice/text-to-speech.php new file mode 100644 index 0000000000..1c4eaca33b --- /dev/null +++ b/examples/venice/text-to-speech.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Venice\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Text; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('VENICE_API_KEY'), httpClient: http_client()); + +$result = $platform->invoke('tts-kokoro', new Text('Hello world'), [ + 'voice' => 'am_liam', +]); + +echo $result->asBinary().\PHP_EOL; diff --git a/splitsh.json b/splitsh.json index 71d4559046..2911a4d0d3 100644 --- a/splitsh.json +++ b/splitsh.json @@ -69,6 +69,7 @@ "ai-replicate-platform": "src/platform/src/Bridge/Replicate", "ai-scaleway-platform": "src/platform/src/Bridge/Scaleway", "ai-transformers-php-platform": "src/platform/src/Bridge/TransformersPhp", + "ai-venice-platform": "src/platform/src/Bridge/Venice", "ai-vertex-ai-platform": "src/platform/src/Bridge/VertexAi", "ai-voyage-platform": "src/platform/src/Bridge/Voyage", "ai-store": { diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 4cd85c6fdc..ca688d12aa 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -52,6 +52,7 @@ ->append($import('platform/perplexity')) ->append($import('platform/scaleway')) ->append($import('platform/transformersphp')) + ->append($import('platform/venice')) ->append($import('platform/vertexai')) ->append($import('platform/voyage')) ->end() diff --git a/src/ai-bundle/config/platform/venice.php b/src/ai-bundle/config/platform/venice.php new file mode 100644 index 0000000000..9daf40fba8 --- /dev/null +++ b/src/ai-bundle/config/platform/venice.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Config\Definition\Configurator; + +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; + +return (new ArrayNodeDefinition('voyage')) + ->children() + ->stringNode('api_key')->end() + ->stringNode('endpoint') + ->defaultValue('https://api.venice.ai/api/v1/') + ->end() + ->stringNode('http_client') + ->defaultValue('http_client') + ->info('Service ID of the HTTP client to use') + ->end() + ->end(); diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 6f663dcf17..e72055998f 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -56,6 +56,8 @@ use Symfony\AI\Platform\Bridge\Replicate\ModelCatalog as ReplicateModelCatalog; use Symfony\AI\Platform\Bridge\Scaleway\ModelCatalog as ScalewayModelCatalog; use Symfony\AI\Platform\Bridge\TransformersPhp\ModelCatalog as TransformersPhpModelCatalog; +use Symfony\AI\Platform\Bridge\Venice\Contract\Contract as VeniceContract; +use Symfony\AI\Platform\Bridge\Venice\ModelCatalog as VeniceModelCatalog; use Symfony\AI\Platform\Bridge\VertexAi\Contract\GeminiContract as VertexAiGeminiContract; use Symfony\AI\Platform\Bridge\VertexAi\ModelCatalog as VertexAiModelCatalog; use Symfony\AI\Platform\Bridge\Voyage\ModelCatalog as VoyageModelCatalog; @@ -90,6 +92,8 @@ ->factory([GeminiContract::class, 'create']) ->set('ai.platform.contract.huggingface', Contract::class) ->factory([HuggingFaceContract::class, 'create']) + ->set('ai.platform.contract.venice', Contract::class) + ->factory([VeniceContract::class, 'create']) ->set('ai.platform.contract.vertexai.gemini', Contract::class) ->factory([VertexAiGeminiContract::class, 'create']) ->set('ai.platform.contract.ollama', Contract::class) diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 47470213de..9b7c74da1e 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -77,6 +77,7 @@ use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory as PerplexityPlatformFactory; use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory as ScalewayPlatformFactory; use Symfony\AI\Platform\Bridge\TransformersPhp\PlatformFactory as TransformersPhpPlatformFactory; +use Symfony\AI\Platform\Bridge\Venice\PlatformFactory as VenicePlatformFactory; use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory as VertexAiPlatformFactory; use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory as VoyagePlatformFactory; use Symfony\AI\Platform\Capability; @@ -722,6 +723,30 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('venice' === $type) { + if (!ContainerBuilder::willBeAvailable('symfony/ai-venice-platform', VoyagePlatformFactory::class, ['symfony/ai-bundle'])) { + throw new RuntimeException('Voyage platform configuration requires "symfony/ai-venice-platform" package. Try running "composer require symfony/ai-venice-platform".'); + } + + $definition = (new Definition(Platform::class)) + ->setFactory(VenicePlatformFactory::class.'::create') + ->setLazy(true) + ->setArguments([ + $platform['api_key'] ?? null, + $platform['endpoint'], + new Reference($platform['http_client']), + new Reference('ai.platform.contract.'.$type), + new Reference('event_dispatcher'), + ]) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->addTag('ai.platform', ['name' => $type]); + + $container->setDefinition('ai.platform.'.$type, $definition); + $container->registerAliasForArgument('ai.platform.'.$type, PlatformInterface::class, $type); + + return; + } + if ('vertexai' === $type && isset($platform['location'], $platform['project_id'])) { if (!ContainerBuilder::willBeAvailable('symfony/ai-vertex-ai-platform', VertexAiPlatformFactory::class, ['symfony/ai-bundle'])) { throw new RuntimeException('VertexAI platform configuration requires "symfony/ai-vertex-ai-platform" package. Try running "composer require symfony/ai-vertex-ai-platform".'); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index c7db0efd3a..644fe5f9fe 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -7872,6 +7872,9 @@ private function getFullConfig(): array 'voyage' => [ 'api_key' => 'voyage_key_full', ], + 'venice' => [ + 'api_key' => 'venice_api_key', + ], 'vertexai' => [ 'location' => 'global', 'project_id' => '123', diff --git a/src/platform/src/Bridge/Venice/.gitattributes b/src/platform/src/Bridge/Venice/.gitattributes new file mode 100644 index 0000000000..14c3c35940 --- /dev/null +++ b/src/platform/src/Bridge/Venice/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/platform/src/Bridge/Venice/.github/PULL_REQUEST_TEMPLATE.md b/src/platform/src/Bridge/Venice/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..fcb87228ae --- /dev/null +++ b/src/platform/src/Bridge/Venice/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/platform/src/Bridge/Venice/.github/workflows/close-pull-request.yml b/src/platform/src/Bridge/Venice/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000..bb5a02835b --- /dev/null +++ b/src/platform/src/Bridge/Venice/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/platform/src/Bridge/Venice/.gitignore b/src/platform/src/Bridge/Venice/.gitignore new file mode 100644 index 0000000000..76367ee5bb --- /dev/null +++ b/src/platform/src/Bridge/Venice/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/platform/src/Bridge/Venice/CHANGELOG.md b/src/platform/src/Bridge/Venice/CHANGELOG.md new file mode 100644 index 0000000000..1a1653d487 --- /dev/null +++ b/src/platform/src/Bridge/Venice/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.5 +--- + + * Add the bridge diff --git a/src/platform/src/Bridge/Venice/Contract/Contract.php b/src/platform/src/Bridge/Venice/Contract/Contract.php new file mode 100644 index 0000000000..5cfeb10a9d --- /dev/null +++ b/src/platform/src/Bridge/Venice/Contract/Contract.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice\Contract; + +use Symfony\AI\Platform\Contract as PlatformContract; + +/** + * @author Guillaume Loulier + */ +final class Contract extends PlatformContract +{ +} diff --git a/src/platform/src/Bridge/Venice/LICENSE b/src/platform/src/Bridge/Venice/LICENSE new file mode 100644 index 0000000000..bc38d714ef --- /dev/null +++ b/src/platform/src/Bridge/Venice/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/platform/src/Bridge/Venice/ModelCatalog.php b/src/platform/src/Bridge/Venice/ModelCatalog.php new file mode 100644 index 0000000000..0562187856 --- /dev/null +++ b/src/platform/src/Bridge/Venice/ModelCatalog.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final class ModelCatalog implements ModelCatalogInterface +{ + public function __construct( + private readonly HttpClientInterface $httpClient, + ) { + } + + public function getModel(string $modelName): Venice + { + $models = $this->getModels(); + + if (!\array_key_exists($modelName, $models)) { + throw new InvalidArgumentException(\sprintf('The model "%s" cannot be retrieved from the API.', $modelName)); + } + + if ([] === $models[$modelName]['capabilities']) { + throw new InvalidArgumentException(\sprintf('The model "%s" is not supported, please check the Venice API.', $modelName)); + } + + return new Venice($modelName, $models[$modelName]['capabilities']); + } + + public function getModels(): array + { + $results = $this->httpClient->request('GET', 'models', [ + 'query' => [ + 'type' => 'all', + ], + ]); + + $models = $results->toArray(); + + if ([] === $models['data']) { + return []; + } + + $payload = static fn (array $model): array => match ($model['type']) { + 'asr' => [ + 'class' => Venice::class, + 'capabilities' => [ + Capability::SPEECH_RECOGNITION, + Capability::INPUT_TEXT, + ], + ], + 'embedding' => [ + 'class' => Venice::class, + 'capabilities' => [ + Capability::EMBEDDINGS, + Capability::INPUT_TEXT, + ], + ], + 'text' => [ + 'class' => Venice::class, + 'capabilities' => [ + Capability::INPUT_TEXT, + Capability::INPUT_MESSAGES, + ], + ], + 'tts' => [ + 'class' => Venice::class, + 'capabilities' => [ + Capability::TEXT_TO_SPEECH, + Capability::INPUT_TEXT, + ], + ], + 'video' => [ + 'class' => Venice::class, + 'capabilities' => [ + Capability::IMAGE_TO_VIDEO, + Capability::INPUT_IMAGE, + ], + ], + }; + + return array_combine( + array_map(static fn (array $model): string => $model['id'], $models['data']), + array_map(static fn (array $model): array => $payload($model), $models['data']), + ); + } +} diff --git a/src/platform/src/Bridge/Venice/PlatformFactory.php b/src/platform/src/Bridge/Venice/PlatformFactory.php new file mode 100644 index 0000000000..74916b9978 --- /dev/null +++ b/src/platform/src/Bridge/Venice/PlatformFactory.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\AI\Platform\Bridge\Venice\Contract\Contract as VeniceContract; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\Platform; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] ?string $apiKey = null, + string $endpoint = 'https://api.venice.ai/api/v1/', + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, + ): PlatformInterface { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + if (null !== $apiKey) { + $httpClient = ScopingHttpClient::forBaseUri($httpClient, $endpoint, [ + 'auth_bearer' => $apiKey, + ]); + } + + return new Platform( + [new VeniceClient($httpClient)], + [new ResultConverter()], + new ModelCatalog($httpClient), + $contract ?? VeniceContract::create(), + $eventDispatcher, + ); + } +} diff --git a/src/platform/src/Bridge/Venice/README.md b/src/platform/src/Bridge/Venice/README.md new file mode 100644 index 0000000000..004eb6bfaf --- /dev/null +++ b/src/platform/src/Bridge/Venice/README.md @@ -0,0 +1,12 @@ +Venice Platform +=============== + +Venice platform bridge for Symfony AI. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/platform/src/Bridge/Venice/ResultConverter.php b/src/platform/src/Bridge/Venice/ResultConverter.php new file mode 100644 index 0000000000..5dbed1a88b --- /dev/null +++ b/src/platform/src/Bridge/Venice/ResultConverter.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\BinaryResult; +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\VectorResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Platform\Vector\VectorInterface; +use Symfony\Component\JsonPath\JsonCrawler; +use Symfony\Component\String\UnicodeString; + +/** + * @author Guillaume Loulier + */ +final class ResultConverter implements ResultConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Venice; + } + + public function convert(RawResultInterface $result, array $options = []): ResultInterface + { + $crawler = new JsonCrawler($result->getObject()->getContent()); + + return match (true) { + (new UnicodeString($result->getObject()->getInfo('url')))->containsAny('completions') && [] !== $crawler->find('$.choices[0].message.content') => new TextResult($crawler->find('$.choices[0].message.content')[0]), + ((new UnicodeString($result->getObject()->getInfo('url')))->containsAny('completions') && $options['stream'] ?? false) && [] !== $crawler->find('$.choices') => new StreamResult($this->convertCompletionToGenerator($crawler->find('$.choices'))), + (new UnicodeString($result->getObject()->getInfo('url')))->containsAny('speech') => new BinaryResult($result->getObject()->getContent()), + (new UnicodeString($result->getObject()->getInfo('url')))->containsAny('embeddings') && [] !== $crawler->find('$.data[0].embedding') => new VectorResult(...array_map( + static fn (array $embeddings): VectorInterface => new Vector($embeddings), + $crawler->find('$.data[0].embedding'), + )), + default => throw new RuntimeException('Unsupported model capability.'), + }; + } + + public function getTokenUsageExtractor(): ?TokenUsageExtractorInterface + { + return new TokenUsageExtractor(); + } + + private function convertCompletionToGenerator(array $choices): \Generator + { + + } +} diff --git a/src/platform/src/Bridge/Venice/Tests/ModelCatalogTest.php b/src/platform/src/Bridge/Venice/Tests/ModelCatalogTest.php new file mode 100644 index 0000000000..d7e83ddb2c --- /dev/null +++ b/src/platform/src/Bridge/Venice/Tests/ModelCatalogTest.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Venice\ModelCatalog; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +final class ModelCatalogTest extends TestCase +{ + public function testModelCatalogCannotReturnModelFromApiWhenUndefined() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse(['data' => []]), + ]); + + $modelCatalog = new ModelCatalog($httpClient); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The model "foo" cannot be retrieved from the API.'); + $this->expectExceptionCode(0); + $modelCatalog->getModel('foo'); + } + + public function testModelCatalogCannotReturnUnsupportedModelFromApi() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'createdAt' => (new \DateTimeImmutable())->getTimestamp(), + 'id' => 'llama-3.2-3b', + 'model_spec' => [ + 'capabilities' => [ + 'optimizedForCode' => true, + 'quantization' => 'fp16', + 'supportsFunctionCalling' => true, + 'supportsReasoning' => false, + 'supportsVision' => false, + 'supportsWebSearch' => true, + ], + ], + 'object' => 'model', + 'owned_by' => 'venice.ai', + 'type' => 'text', + ], + [ + 'createdAt' => (new \DateTimeImmutable())->getTimestamp(), + 'id' => 'foo', + 'model_spec' => [ + 'capabilities' => [ + 'optimizedForCode' => false, + 'quantization' => 'fp16', + 'supportsFunctionCalling' => false, + 'supportsReasoning' => false, + 'supportsVision' => false, + 'supportsWebSearch' => false, + ], + ], + 'object' => 'model', + 'owned_by' => 'venice.ai', + 'type' => 'text', + ], + ], + 'object' => 'list', + 'type' => 'all', + ]), + ]); + + $modelCatalog = new ModelCatalog($httpClient); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The model "foo" is not supported, please check the Venice API.'); + $this->expectExceptionCode(0); + $modelCatalog->getModel('foo'); + } + + public function testModelCatalogCanReturnAsrModelFromApi() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'createdAt' => (new \DateTimeImmutable())->getTimestamp(), + 'id' => 'nvidia/parakeet-tdt-0.6b-v3', + 'model_spec' => [ + 'pricing' => [ + 'per_audio_second' => [ + 'usd' => 0.0001, + 'diem' => 0.0001 + ], + ], + ], + 'name' => 'Parakeet ASR', + 'modelSource' => 'https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3', + 'offline' => false, + 'privacy' => 'private', + 'object' => 'model', + 'owned_by' => 'venice.ai', + 'type' => 'asr', + ], + ], + 'object' => 'list', + 'type' => 'all', + ]), + ]); + + $modelCatalog = new ModelCatalog($httpClient); + + $model = $modelCatalog->getModel('nvidia/parakeet-tdt-0.6b-v3'); + + $this->assertSame('nvidia/parakeet-tdt-0.6b-v3', $model->getName()); + $this->assertSame([ + Capability::SPEECH_RECOGNITION, + Capability::INPUT_TEXT, + ], $model->getCapabilities()); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testModelCatalogCanReturnEmbeddingModelFromApi() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'createdAt' => (new \DateTimeImmutable())->getTimestamp(), + 'id' => 'text-embedding-bge-m3', + 'model_spec' => [ + 'pricing' => [ + 'input' => [ + 'usd' => 0.15, + 'diem' => 0.15 + ], + 'output' => [ + 'usd' => 0.6, + 'diem' => 0.6 + ], + ], + ], + 'name' => 'BGE-3', + 'modelSource' => 'https://huggingface.co/BAAI/bge-m3', + 'offline' => false, + 'privacy' => 'private', + 'object' => 'model', + 'owned_by' => 'venice.ai', + 'type' => 'embedding', + ], + ], + 'object' => 'list', + 'type' => 'all', + ]), + ]); + + $modelCatalog = new ModelCatalog($httpClient); + + $model = $modelCatalog->getModel('text-embedding-bge-m3'); + + $this->assertSame('text-embedding-bge-m3', $model->getName()); + $this->assertSame([ + Capability::EMBEDDINGS, + Capability::INPUT_TEXT, + ], $model->getCapabilities()); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testModelCatalogCanReturnImageModelFromApi() + { + + } +} diff --git a/src/platform/src/Bridge/Venice/Tests/ResultConverterTest.php b/src/platform/src/Bridge/Venice/Tests/ResultConverterTest.php new file mode 100644 index 0000000000..864908eadd --- /dev/null +++ b/src/platform/src/Bridge/Venice/Tests/ResultConverterTest.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice\Tests; + +use PHPUnit\Framework\TestCase; + +final class ResultConverterTest extends TestCase +{ +} diff --git a/src/platform/src/Bridge/Venice/Tests/TokenUsageExtractorTest.php b/src/platform/src/Bridge/Venice/Tests/TokenUsageExtractorTest.php new file mode 100644 index 0000000000..961598fb2f --- /dev/null +++ b/src/platform/src/Bridge/Venice/Tests/TokenUsageExtractorTest.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice\Tests; + +use PHPUnit\Framework\TestCase; + +final class TokenUsageExtractorTest extends TestCase +{ +} diff --git a/src/platform/src/Bridge/Venice/Tests/VeniceClientTest.php b/src/platform/src/Bridge/Venice/Tests/VeniceClientTest.php new file mode 100644 index 0000000000..002b81e1c3 --- /dev/null +++ b/src/platform/src/Bridge/Venice/Tests/VeniceClientTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Venice\Venice; +use Symfony\AI\Platform\Bridge\Venice\VeniceClient; +use Symfony\AI\Platform\Capability; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +final class VeniceClientTest extends TestCase +{ + public function testClientCanTriggerCompletion() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'index' => 0, + 'logprobs' => null, + 'message' => [ + 'content' => 'foo', + 'reasoning_content' => null, + 'role' => 'assistant', + 'tool_calls' => [], + ], + 'stop_reason' => null, + ], + ], + 'model' => 'text-embedding-bge-m3', + 'object' => 'list', + 'usage' => [ + 'prompt_tokens' => 8, + 'total_tokens' => 8, + ], + ]), + ], 'https://api.venice.ai/api/v1/'); + + $client = new VeniceClient($httpClient); + + $client->request(new Venice('text-embedding-bge-m3', [ + Capability::EMBEDDINGS, + ]), 'foo'); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testClientCanTriggerCompletionAsStream() + { + } + + public function testClientCanTriggerImageGeneration() + { + } + + public function testClientCanTriggerTextToSpeech() + { + } + + public function testClientCanTriggerEmbeddings() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'embedding' => [ + 0.0023064255, + -0.009327292, + 0.015797377, + ], + 'index' => 0, + 'object' => 'embedding', + ], + ], + 'model' => 'text-embedding-bge-m3', + 'object' => 'list', + 'usage' => [ + 'prompt_tokens' => 8, + 'total_tokens' => 8, + ], + ]), + ], 'https://api.venice.ai/api/v1/'); + + $client = new VeniceClient($httpClient); + + $client->request(new Venice('text-embedding-bge-m3', [ + Capability::EMBEDDINGS, + ]), 'foo'); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } +} diff --git a/src/platform/src/Bridge/Venice/TokenUsageExtractor.php b/src/platform/src/Bridge/Venice/TokenUsageExtractor.php new file mode 100644 index 0000000000..9f460d1dc8 --- /dev/null +++ b/src/platform/src/Bridge/Venice/TokenUsageExtractor.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\TokenUsage\TokenUsage; +use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface; +use Symfony\AI\Platform\TokenUsage\TokenUsageInterface; +use Symfony\Component\String\UnicodeString; + +/** + * @author Guillaume Loulier + */ +final class TokenUsageExtractor implements TokenUsageExtractorInterface +{ + public function extract(RawResultInterface $rawResult, array $options = []): ?TokenUsageInterface + { + $url = $rawResult->getObject()->getInfo('url'); + + if ((new UnicodeString($url))->containsAny('speech')) { + return null; + } + + $content = $rawResult->getData(); + + return match (true) { + (new UnicodeString($url))->containsAny('completions') => new TokenUsage( + promptTokens: $content['usage']['prompt_tokens'], + completionTokens: $content['usage']['completion_tokens'], + totalTokens: $content['usage']['total_tokens'], + ), + (new UnicodeString($url))->containsAny('embeddings') => new TokenUsage( + promptTokens: $content['usage']['prompt_tokens'], + totalTokens: $content['usage']['total_tokens'], + ), + default => null, + }; + } +} diff --git a/src/platform/src/Bridge/Venice/Venice.php b/src/platform/src/Bridge/Venice/Venice.php new file mode 100644 index 0000000000..547fee32a0 --- /dev/null +++ b/src/platform/src/Bridge/Venice/Venice.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Symfony\AI\Platform\Model; + +/** + * @author Guillaume Loulier + */ +class Venice extends Model +{ +} diff --git a/src/platform/src/Bridge/Venice/VeniceClient.php b/src/platform/src/Bridge/Venice/VeniceClient.php new file mode 100644 index 0000000000..3b4da307c9 --- /dev/null +++ b/src/platform/src/Bridge/Venice/VeniceClient.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final class VeniceClient implements ModelClientInterface +{ + public function __construct( + private readonly HttpClientInterface $httpClient, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Venice; + } + + public function request(Model $model, array|string $payload, array $options = []): RawResultInterface + { + return match (true) { + $model->supports(Capability::INPUT_MESSAGES) => $this->doGenerateCompletion($model, $payload, $options), + $model->supports(Capability::TEXT_TO_SPEECH) => $this->doTextToSpeech($model, $payload, $options), + $model->supports(Capability::EMBEDDINGS) => $this->doGenerateEmbeddings($model, $payload), + default => throw new InvalidArgumentException('Unsupported model capability for Venice client'), + }; + } + + private function doGenerateCompletion(Model $model, array|string $payload, array $options): RawResultInterface + { + if (\is_array($payload) && !\array_key_exists('messages', $payload)) { + throw new InvalidArgumentException('Payload must contain "messages" key for completion.'); + } + + if (($options['stream'] ?? false) && !isset($options['stream_options']['include_usage'])) { + $options['stream_options']['include_usage'] = true; + } + + return new RawHttpResult($this->httpClient->request('POST', 'chat/completions', [ + 'json' => [ + 'messages' => $payload['messages'], + 'model' => $model->getName(), + ...$options, + ], + ])); + } + + private function doTextToSpeech(Model $model, array|string $payload, array $options): RawResultInterface + { + return new RawHttpResult($this->httpClient->request('POST', 'audio/speech', [ + 'json' => [ + 'response_format' => 'mp3', + 'input' => \is_string($payload) ? $payload : $payload['text'], + 'model' => $model->getName(), + ...$options, + ], + ])); + } + + private function doGenerateEmbeddings(Model $model, array|string $payload): RawResultInterface + { + return new RawHttpResult($this->httpClient->request('POST', 'embeddings', [ + 'json' => [ + 'encoding_format' => 'float', + 'input' => \is_string($payload) ? $payload : $payload['text'], + 'model' => $model->getName(), + ], + ])); + } +} diff --git a/src/platform/src/Bridge/Venice/VeniceMessageChunk.php b/src/platform/src/Bridge/Venice/VeniceMessageChunk.php new file mode 100644 index 0000000000..0025dd246a --- /dev/null +++ b/src/platform/src/Bridge/Venice/VeniceMessageChunk.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Venice; + +/** + * @author Guillaume Loulier + */ +final class VeniceMessageChunk implements \Stringable +{ + public function __toString() + { + // TODO: Implement __toString() method. + } +} diff --git a/src/platform/src/Bridge/Venice/composer.json b/src/platform/src/Bridge/Venice/composer.json new file mode 100644 index 0000000000..f2d64bbab7 --- /dev/null +++ b/src/platform/src/Bridge/Venice/composer.json @@ -0,0 +1,57 @@ +{ + "name": "symfony/ai-venice-platform", + "description": "Venice platform bridge for Symfony AI", + "license": "MIT", + "type": "symfony-ai-platform", + "keywords": [ + "ai", + "bridge", + "local", + "venice", + "platform" + ], + "authors": [ + { + "name": "Guillaume Loulier", + "email": "personal@guillaumeloulier.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-platform": "^0.5", + "symfony/http-client": "^7.3|^8.0", + "symfony/json-path": "^7.3|^8.0", + "symfony/string": "^7.3|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\Bridge\\Venice\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/", + "Symfony\\AI\\Platform\\Bridge\\Venice\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/platform/src/Bridge/Venice/phpstan.dist.neon b/src/platform/src/Bridge/Venice/phpstan.dist.neon new file mode 100644 index 0000000000..236fccdc76 --- /dev/null +++ b/src/platform/src/Bridge/Venice/phpstan.dist.neon @@ -0,0 +1,29 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - ../../../../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - . + - Tests/ + excludePaths: + - vendor/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + reportUnmatched: false + - + message: '#^Call to( static)? method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#' + reportUnmatched: false + - + identifier: 'symfonyAi.forbidNativeException' + path: Tests/* + reportUnmatched: false + +services: + - # Conditionally enabled by bleeding edge in phpstan/phpstan-phpunit 2.x + class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension + tags: + - phpstan.ignoreErrorExtension diff --git a/src/platform/src/Bridge/Venice/phpunit.xml.dist b/src/platform/src/Bridge/Venice/phpunit.xml.dist new file mode 100644 index 0000000000..fbec00b9bf --- /dev/null +++ b/src/platform/src/Bridge/Venice/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/platform/src/Capability.php b/src/platform/src/Capability.php index cf48a3b686..a73e1e6ce7 100644 --- a/src/platform/src/Capability.php +++ b/src/platform/src/Capability.php @@ -42,6 +42,7 @@ enum Capability: string // VOICE case TEXT_TO_SPEECH = 'text-to-speech'; case SPEECH_TO_TEXT = 'speech-to-text'; + case SPEECH_RECOGNITION = 'speech-recognition'; // IMAGE case TEXT_TO_IMAGE = 'text-to-image';