diff --git a/README.md b/README.md index cd26dba1..ac65db86 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Symfony AI is a set of components that integrate AI capabilities into PHP applic Symfony AI consists of several lower and higher level **components** and the respective integration **bundles**: * **Components** - * **[Platform](src/platform/README.md)**: A unified interface to various AI platforms like OpenAI, Anthropic, Azure, Gemini, and more. + * **[Platform](src/platform/README.md)**: A unified interface to various AI platforms like OpenAI, Anthropic, Azure, Gemini, VertexAI, and more. * **[Agent](src/agent/README.md)**: Framework for building AI agents that can interact with users and perform tasks. * **[Store](src/store/README.md)**: Data storage abstraction with indexing and retrieval for AI applications. * **[MCP SDK](src/mcp-sdk/README.md)**: SDK for [Model Context Protocol](https://modelcontextprotocol.io) enabling communication between AI agents and tools. diff --git a/examples/.env b/examples/.env index e25d79d0..3c54c01b 100644 --- a/examples/.env +++ b/examples/.env @@ -72,6 +72,10 @@ RUN_EXPENSIVE_EXAMPLES=false # For using Gemini GEMINI_API_KEY= +# Vertex AI +GOOGLE_CLOUD_LOCATION=global +GOOGLE_CLOUD_PROJECT=GOOGLE_CLOUD_PROJECT + # For using Albert API (French Sovereign AI) ALBERT_API_KEY= ALBERT_API_URL= diff --git a/examples/composer.json b/examples/composer.json index 43646835..38ad5a89 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -8,6 +8,7 @@ "async-aws/bedrock-runtime": "^1.1", "codewithkyrian/transformers": "^0.6.1", "doctrine/dbal": "^3.3|^4.0", + "google/auth": "^1.47", "mrmysql/youtube-transcript": "^0.0.5", "php-http/discovery": "^1.20", "probots-io/pinecone-php": "^1.1", diff --git a/examples/vertexai/audio-input.php b/examples/vertexai/audio-input.php new file mode 100644 index 00000000..fff59e47 --- /dev/null +++ b/examples/vertexai/audio-input.php @@ -0,0 +1,33 @@ + + * + * 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\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_FLASH); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::ofUser( + 'What is this recording about?', + Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'), + ), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/vertexai/bootstrap.php b/examples/vertexai/bootstrap.php new file mode 100644 index 00000000..081c7d71 --- /dev/null +++ b/examples/vertexai/bootstrap.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 Google\Auth\ApplicationDefaultCredentials; +use Psr\Log\LoggerAwareInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +require_once dirname(__DIR__).'/bootstrap.php'; + +function adc_aware_http_client(): HttpClientInterface +{ + $credentials = ApplicationDefaultCredentials::getCredentials(['https://www.googleapis.com/auth/cloud-platform']); + $httpClient = HttpClient::create([ + 'auth_bearer' => $credentials?->fetchAuthToken()['access_token'] ?? null, + ]); + + if ($httpClient instanceof LoggerAwareInterface) { + $httpClient->setLogger(logger()); + } + + return $httpClient; +} diff --git a/examples/vertexai/chat.php b/examples/vertexai/chat.php new file mode 100644 index 00000000..d95b6dda --- /dev/null +++ b/examples/vertexai/chat.php @@ -0,0 +1,30 @@ + + * + * 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\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_FLASH); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are an expert assistant in geography.'), + Message::ofUser('Where is Mount Fuji?'), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/vertexai/embeddings.php b/examples/vertexai/embeddings.php new file mode 100644 index 00000000..0607e8f6 --- /dev/null +++ b/examples/vertexai/embeddings.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$embeddings = new Model(); + +$result = $platform->invoke($embeddings, <<asVectors()[0]->getDimensions().\PHP_EOL; diff --git a/examples/vertexai/image-input.php b/examples/vertexai/image-input.php new file mode 100644 index 00000000..a693ed4c --- /dev/null +++ b/examples/vertexai/image-input.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\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_PRO); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/fixtures/image.jpg'), + ), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/vertexai/pdf-input-binary.php b/examples/vertexai/pdf-input-binary.php new file mode 100644 index 00000000..5e9457ae --- /dev/null +++ b/examples/vertexai/pdf-input-binary.php @@ -0,0 +1,33 @@ + + * + * 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\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Document; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_FLASH); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::ofUser( + Document::fromFile(dirname(__DIR__, 2).'/fixtures/document.pdf'), + 'What is this document about?', + ), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/vertexai/server-tools.php b/examples/vertexai/server-tools.php new file mode 100644 index 00000000..beedce97 --- /dev/null +++ b/examples/vertexai/server-tools.php @@ -0,0 +1,38 @@ + + * + * 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\Clock; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); + +$model = new Model(Model::GEMINI_2_5_PRO); + +$toolbox = new Toolbox([new Clock()], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); + +$content = file_get_contents('https://www.euribor-rates.eu/en/current-euribor-rates/4/euribor-rate-12-months/'); +$messages = new MessageBag( + Message::ofUser("Based on the following page content, what was the 12-month Euribor rate a week ago?\n\n".$content) +); + +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/vertexai/stream.php b/examples/vertexai/stream.php new file mode 100644 index 00000000..8c72d3d9 --- /dev/null +++ b/examples/vertexai/stream.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\Agent\Agent; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_FLASH); + +$agent = new Agent($platform, $model, logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are an expert assistant in geography.'), + Message::ofUser('Where is Mount Fuji?'), +); + +$result = $agent->call($messages, [ + 'stream' => true, +]); + +foreach ($result->getContent() as $word) { + echo $word; +} + +echo \PHP_EOL; diff --git a/examples/vertexai/structured-output-clock.php b/examples/vertexai/structured-output-clock.php new file mode 100644 index 00000000..0e97cff8 --- /dev/null +++ b/examples/vertexai/structured-output-clock.php @@ -0,0 +1,52 @@ + + * + * 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\StructuredOutput\AgentProcessor as StructuredOutputProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Clock; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Clock\Clock as SymfonyClock; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_PRO); + +$clock = new Clock(new SymfonyClock()); +$toolbox = new Toolbox([$clock]); +$toolProcessor = new ToolProcessor($toolbox); +$structuredOutputProcessor = new StructuredOutputProcessor(); +$agent = new Agent($platform, $model, [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor], logger()); + +$messages = new MessageBag(Message::ofUser('What date and time is it?')); +$result = $agent->call($messages, ['response_format' => [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'clock', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'], + 'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'], + ], + 'required' => ['date', 'time'], + 'additionalProperties' => false, + ], + ], +]]); + +dump($result->getContent()); diff --git a/examples/vertexai/structured-output-math.php b/examples/vertexai/structured-output-math.php new file mode 100644 index 00000000..01df76ba --- /dev/null +++ b/examples/vertexai/structured-output-math.php @@ -0,0 +1,33 @@ + + * + * 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\StructuredOutput\AgentProcessor; +use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_5_FLASH_LITE); + +$processor = new AgentProcessor(); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); + +dump($result->getContent()); diff --git a/examples/vertexai/token-metadata.php b/examples/vertexai/token-metadata.php new file mode 100644 index 00000000..9602a22d --- /dev/null +++ b/examples/vertexai/token-metadata.php @@ -0,0 +1,35 @@ + + * + * 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\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_0_FLASH_LITE); + +$agent = new Agent($platform, $model, outputProcessors: [new Symfony\AI\Platform\Bridge\VertexAi\TokenOutputProcessor()], logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are an expert assistant in animal study.'), + Message::ofUser('What does a cat usually eat?'), +); +$result = $agent->call($messages); + +$metadata = $result->getMetadata(); + +echo 'Prompt Tokens: '.$metadata['prompt_tokens'].\PHP_EOL; +echo 'Completion Tokens: '.$metadata['completion_tokens'].\PHP_EOL; +echo 'Thinking Tokens: '.$metadata['thinking_tokens'].\PHP_EOL; +echo 'Utilized Tokens: '.$metadata['total_tokens'].\PHP_EOL; diff --git a/examples/vertexai/toolcall.php b/examples/vertexai/toolcall.php new file mode 100644 index 00000000..52cd8cbf --- /dev/null +++ b/examples/vertexai/toolcall.php @@ -0,0 +1,33 @@ + + * + * 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\Clock; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once __DIR__.'/bootstrap.php'; + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$model = new Model(Model::GEMINI_2_0_FLASH_LITE); + +$toolbox = new Toolbox([new Clock()], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); + +$messages = new MessageBag(Message::ofUser('What time is it?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index fe409dff..283d7844 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -34,7 +34,7 @@ Configuration class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt' name: !php/const Symfony\AI\Platform\Bridge\OpenAi\Gpt::GPT_4O_MINI -**Advanced Example with Anthropic, Azure, Gemini and multiple agents** +**Advanced Example with Anthropic, Azure, Gemini, Vertex AI and multiple agents** .. code-block:: yaml @@ -52,6 +52,9 @@ Configuration api_version: '%env(AZURE_GPT_VERSION)%' gemini: api_key: '%env(GEMINI_API_KEY)%' + vertexai: + location: '%env(GOOGLE_CLOUD_LOCATION)%' + project_id: '%env(GOOGLE_CLOUD_PROJECT)%' ollama: host_url: '%env(OLLAMA_HOST_URL)%' agent: diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 580a8668..76f0a9b9 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -11,6 +11,7 @@ namespace Symfony\AI\AiBundle; +use Google\Auth\ApplicationDefaultCredentials; use Symfony\AI\Agent\Agent; use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; @@ -34,6 +35,8 @@ use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory; +use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory as VertexAiPlatformFactory; +use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; use Symfony\AI\Platform\Platform; @@ -63,6 +66,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -224,6 +228,37 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('vertexai' === $type && isset($platform['location'], $platform['project_id'])) { + if (!class_exists(ApplicationDefaultCredentials::class)) { + throw new RuntimeException('For using the Vertex AI platform, google/auth package is required. Try running "composer require google/auth".'); + } + + $credentials = ApplicationDefaultCredentials::getCredentials([ + 'https://www.googleapis.com/auth/cloud-platform', + ]); + + $httpClient = new EventSourceHttpClient(HttpClient::create([ + 'auth_bearer' => $credentials?->fetchAuthToken()['access_token'] ?? null, + ])); + + $platformId = 'ai.platform.vertexai'; + $definition = (new Definition(Platform::class)) + ->setFactory([VertexAiPlatformFactory::class, 'create']) + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + $platform['location'], + $platform['project_id'], + $httpClient, + new Reference('ai.platform.contract.vertexai', ContainerInterface::NULL_ON_INVALID_REFERENCE), + ]) + ->addTag('ai.platform'); + + $container->setDefinition($platformId, $definition); + + return; + } + if ('openai' === $type) { $platformId = 'ai.platform.openai'; $definition = (new Definition(Platform::class)) diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 1570ad0f..33e4cae3 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -9,7 +9,7 @@ CHANGELOG * Add support for 13+ AI providers: - OpenAI (GPT-4, GPT-3.5, DALL·E, Whisper) - Anthropic (Claude models via native API and AWS Bedrock) - - Google (Gemini models with server-side tools support) + - Google (VertexAi and Gemini models with server-side tools support) - Azure (OpenAI and Meta Llama models) - AWS Bedrock (Anthropic Claude, Meta Llama, Amazon Nova) - Mistral AI (language models and embeddings) diff --git a/src/platform/composer.json b/src/platform/composer.json index d8d3b622..94e1227a 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -37,6 +37,7 @@ "require-dev": { "async-aws/bedrock-runtime": "^0.1.0", "codewithkyrian/transformers": "^0.5.3", + "google/auth": "^1.47", "phpstan/phpstan": "^2.1.17", "phpstan/phpstan-symfony": "^2.0.6", "phpunit/phpunit": "^11.5", diff --git a/src/platform/doc/index.rst b/src/platform/doc/index.rst index 98dc0fb3..78ce7286 100644 --- a/src/platform/doc/index.rst +++ b/src/platform/doc/index.rst @@ -73,13 +73,15 @@ usually defined by the specific models and their documentation. * `OpenAI's GPT`_ with `OpenAI`_ and `Azure`_ as Platform * `Anthropic's Claude`_ with `Anthropic`_ and `AWS Bedrock`_ as Platform * `Meta's Llama`_ with `Azure`_, `Ollama`_, `Replicate`_ and `AWS Bedrock`_ as Platform - * `Gemini`_ with `Google`_ and `OpenRouter`_ as Platform + * `Gemini`_ with `Google`_, `Vertex AI`_ and `OpenRouter`_ as Platform + * `Vertex AI Gen AI`_ with `Vertex AI`_ as Platform * `DeepSeek's R1`_ with `OpenRouter`_ as Platform * `Amazon's Nova`_ with `AWS Bedrock`_ as Platform * `Mistral's Mistral`_ with `Mistral`_ as Platform * `Albert API`_ models with `Albert`_ as Platform (French government's sovereign AI gateway) * **Embeddings Models** * `Gemini Text Embeddings`_ with `Google`_ + * `Vertex AI Text Embeddings`_ with `Vertex AI`_ * `OpenAI's Text Embeddings`_ with `OpenAI`_ and `Azure`_ as Platform * `Voyage's Embeddings`_ with `Voyage`_ as Platform * `Mistral Embed`_ with `Mistral`_ as Platform @@ -263,7 +265,8 @@ Server Tools Some platforms provide built-in server-side tools for enhanced capabilities without custom implementations: -1. **[Gemini](gemini-server-tools.md)** - URL Context, Google Search, Code Execution +1. **[Gemini](gemini-server-tools.rst)** - URL Context, Google Search, Code Execution +2. **[Vertex AI](vertexai-server-tools.rst)** - URL Context, Google Search, Code Execution Parallel Platform Calls ----------------------- @@ -363,6 +366,7 @@ This allows fast and isolated testing of AI-powered features without relying on .. _`Ollama`: https://ollama.com/ .. _`Replicate`: https://replicate.com/ .. _`Gemini`: https://gemini.google.com/ +.. _`Vertex AI`: https://cloud.google.com/vertex-ai/generative-ai/docs .. _`Google`: https://ai.google.dev/ .. _`OpenRouter`: https://www.openrouter.ai/ .. _`DeepSeek's R1`: https://www.deepseek.com/ @@ -372,6 +376,8 @@ This allows fast and isolated testing of AI-powered features without relying on .. _`Albert`: https://alliance.numerique.gouv.fr/produit/albert/ .. _`Mistral`: https://www.mistral.ai/ .. _`Gemini Text Embeddings`: https://ai.google.dev/gemini-api/docs/embeddings +.. _`Vertex AI Gen AI`: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference +.. _`Vertex AI Text Embeddings`: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api .. _`OpenAI's Text Embeddings`: https://platform.openai.com/docs/guides/embeddings/embedding-models .. _`Voyage's Embeddings`: https://docs.voyageai.com/docs/embeddings .. _`Voyage`: https://www.voyageai.com/ diff --git a/src/platform/doc/vertexai-server-tools.rst b/src/platform/doc/vertexai-server-tools.rst new file mode 100644 index 00000000..8a710389 --- /dev/null +++ b/src/platform/doc/vertexai-server-tools.rst @@ -0,0 +1,111 @@ +Vertex AI Server Tools +====================== + +Server tools in Vertex AI are built-in capabilities provided by Google's Gemini models that allow the model to perform +specific actions without requiring custom tool implementations. +These tools run on Google's infrastructure and provide access to external data sources and execution environments. + +Overview +-------- + +Vertex AI provides several server-side tools that can be enabled when calling the model: + +- **URL Context** - Fetches and analyzes content from URLs +- **Grounding** - Lets a model output connect to verifiable sources of information. +- **Code Execution** - Executes code in a sandboxed environment. + +Available Server Tools +---------------------- + +**URL Context** + +The URL Context tool allows the model to fetch and analyze content from specified web pages. This is useful for: + +- Analyzing current web content +- Extracting structured information from pages +- Providing context from external documents +- https://cloud.google.com/vertex-ai/generative-ai/docs/url-context + +:: + + $model = new VertexAi\Gemini\Model('gemini-2.5-pro'); + + $content = file_get_contents('https://www.euribor-rates.eu/en/current-euribor-rates/4/euribor-rate-12-months/'); + $messages = new MessageBag( + Message::ofUser("Based on the following page content, what was the 12-month Euribor rate a week ago?\n\n".$content) + ); + + $result = $platform->invoke($model, $messages); + + +**Grounding with Google Search** +The Grounding tool allows the model to connect its responses to verifiable sources of information, enhancing the reliability +of its outputs. More at https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/overview +Below is an example of grounding a model's responses using Google Search, which uses publicly-available web data. + +* Grounding with Google Search * + +Ground a model's responses using Google Search, which uses publicly-available web data. +More info can be found at https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/overview + +:: + + $model = new VertexAi\Gemini\Model('gemini-2.5-pro', [ + 'tools' => [[ + 'googleSearch' => new \stdClass() + ]] + ]); + + $messages = new MessageBag( + Message::ofUser('What are the top breakthroughs in AI in 2025 so far?') + ); + + $result = $platform->invoke($model, $messages); + +**Code Execution** + +Executes code in a Google-managed sandbox environment and returns both the code and its output. +More info can be found at https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/code-execution + +:: + + $model = new Gemini('gemini-2.5-pro-preview-03-25', [ + 'tools' => [[ + 'codeExecution' => new \stdClass() + ]] + ]); + + $messages = new MessageBag( + Message::ofUser('Write Python code to calculate the 50th Fibonacci number and run it') + ); + + $result = $platform->invoke($model, $messages); + + +Using Multiple Server Tools +--------------------------- + +You can enable multiple tools in a single request:: + + $model = new Gemini('gemini-2.5-pro-preview-03-25', [ + 'tools' => [[ + 'googleSearch' => new \stdClass(), + 'codeExecution' => new \stdClass() + ]] + ]); + +Example +------- + +See `examples/vertexai/server-tools.php`_ for a complete working example. + +Limitations +----------- + +- **Model support:** Not all Vertex AI Gemini model versions support all server tools — check the Vertex AI documentation for the chosen model ID. +- **Permissions:** The Vertex AI service account and the models must have the required permissions or scopes to use server tools. +- **Quotas:** Server tools are subject to usage limits and quotas configured in your Google Cloud project. +- **Latency:** Using multiple tools or fetching from slow external sources can increase response time. +- **Regional availability:** Ensure you are using a location that supports the selected model and tools. + +.. _`examples/vertexai/server-tools.php`: https://github.com/symfony/ai/blob/main/examples/vertexai/server-tools.php diff --git a/src/platform/src/Bridge/VertexAi/Contract/AssistantMessageNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/AssistantMessageNormalizer.php new file mode 100644 index 00000000..790f463e --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/AssistantMessageNormalizer.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\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model as BaseModel; + +/** + * @author Junaid Farooq + */ +final class AssistantMessageNormalizer extends ModelContractNormalizer +{ + /** + * @param AssistantMessage $data + * + * @return array{ + * array{ + * text: string, + * functionCall?: array{ + * name: string, + * args?: array + * } + * } + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $normalized = []; + + if (isset($data->content)) { + $normalized[] = ['text' => $data->content]; + } + + if (isset($data->toolCalls[0])) { + $normalized['functionCall'] = [ + 'name' => $data->toolCalls[0]->name, + ]; + + if ($data->toolCalls[0]->arguments) { + $normalized['functionCall']['args'] = $data->toolCalls[0]->arguments; + } + } + + return $normalized; + } + + protected function supportedDataClass(): string + { + return AssistantMessage::class; + } + + protected function supportsModel(BaseModel $model): bool + { + return $model instanceof Model; + } +} diff --git a/src/platform/src/Bridge/VertexAi/Contract/GeminiContract.php b/src/platform/src/Bridge/VertexAi/Contract/GeminiContract.php new file mode 100644 index 00000000..3decac9e --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/GeminiContract.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\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Contract; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Junaid Farooq + */ +final readonly class GeminiContract extends Contract +{ + public static function create(NormalizerInterface ...$normalizer): Contract + { + return parent::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new ToolNormalizer(), + new ToolCallMessageNormalizer(), + new UserMessageNormalizer(), + ...$normalizer, + ); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..c3adb7c8 --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/MessageBagNormalizer.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Junaid Farooq + */ +final class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + /** + * @param MessageBagInterface $data + * + * @return array{ + * contents: list + * }>, + * systemInstruction?: array{parts: array{text: string}[]} + * } + * + * @throws ExceptionInterface + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $requestData = ['contents' => []]; + + if (null !== $systemMessage = $data->getSystemMessage()) { + $requestData['systemInstruction'] = [ + 'parts' => [['text' => $systemMessage->content]], + ]; + } + + foreach ($data->withoutSystemMessage()->getMessages() as $message) { + $requestData['contents'][] = [ + 'role' => $message->getRole()->equals(Role::Assistant) ? 'model' : 'user', + 'parts' => $this->normalizer->normalize($message, $format, $context), + ]; + } + + return $requestData; + } + + protected function supportedDataClass(): string + { + return MessageBag::class; + } + + protected function supportsModel(BaseModel $model): bool + { + return $model instanceof Model; + } +} diff --git a/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php new file mode 100644 index 00000000..1caee0bd --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Model as BaseModel; + +/** + * @author Junaid Farooq + */ +final class ToolCallMessageNormalizer extends ModelContractNormalizer +{ + /** + * @param ToolCallMessage $data + * + * @return array{ + * functionResponse: array{ + * name: string, + * response: array + * } + * }[] + * + * @throws \JsonException + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $resultContent = json_validate($data->content) ? json_decode($data->content, true, 512, \JSON_THROW_ON_ERROR) : $data->content; + + return [[ + 'functionResponse' => array_filter([ + 'name' => $data->toolCall->name, + 'response' => \is_array($resultContent) ? $resultContent : [ + 'rawResponse' => $resultContent, + ], + ]), + ]]; + } + + protected function supportedDataClass(): string + { + return ToolCallMessage::class; + } + + protected function supportsModel(BaseModel $model): bool + { + return $model instanceof Model; + } +} diff --git a/src/platform/src/Bridge/VertexAi/Contract/ToolNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/ToolNormalizer.php new file mode 100644 index 00000000..4d44781f --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/ToolNormalizer.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Junaid Farooq + * + * @phpstan-import-type JsonSchema from Factory + */ +final class ToolNormalizer extends ModelContractNormalizer +{ + /** + * @param Tool $data + * + * @return array{ + * name: string, + * description: string, + * parameters: JsonSchema|array{type: 'object'} + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $parameters = $data->parameters ? $this->removeAdditionalProperties($data->parameters) : null; + + return [ + 'name' => $data->name, + 'description' => $data->description, + 'parameters' => $parameters, + ]; + } + + protected function supportedDataClass(): string + { + return Tool::class; + } + + protected function supportsModel(BaseModel $model): bool + { + return $model instanceof Model; + } + + /** + * @template T of array + * + * @phpstan-param T $data + * + * @phpstan-return T + */ + private function removeAdditionalProperties(array $data): array + { + unset($data['additionalProperties']); + + foreach ($data as &$value) { + if (\is_array($value)) { + $value = $this->removeAdditionalProperties($value); + } + } + + return $data; + } +} diff --git a/src/platform/src/Bridge/VertexAi/Contract/UserMessageNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/UserMessageNormalizer.php new file mode 100644 index 00000000..eb91aa52 --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Contract/UserMessageNormalizer.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\Bridge\VertexAi\Contract; + +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model as BaseModel; + +/** + * @author Junaid Farooq + */ +final class UserMessageNormalizer extends ModelContractNormalizer +{ + /** + * @param UserMessage $data + * + * @return list + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $parts = []; + foreach ($data->content as $content) { + if ($content instanceof Text) { + $parts[] = ['text' => $content->text]; + } + + if ($content instanceof File) { + $parts[] = [ + 'inlineData' => [ + 'mimeType' => $content->getFormat(), + 'data' => $content->asBase64(), + ], + ]; + } + } + + return $parts; + } + + protected function supportedDataClass(): string + { + return UserMessage::class; + } + + protected function supportsModel(BaseModel $model): bool + { + return $model instanceof Model; + } +} diff --git a/src/platform/src/Bridge/VertexAi/Embeddings/Model.php b/src/platform/src/Bridge/VertexAi/Embeddings/Model.php new file mode 100644 index 00000000..99afe8ba --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Embeddings/Model.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Embeddings; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model as BaseModel; + +/** + * @author Junaid Farooq + */ +final class Model extends BaseModel +{ + /** Upto 3072 dimensions */ + public const GEMINI_EMBEDDING_001 = 'gemini-embedding-001'; + /** Upto 768 dimensions */ + public const TEXT_EMBEDDING_005 = 'text-embedding-005'; + /** Upto 768 dimensions */ + public const TEXT_MULTILINGUAL_EMBEDDING_002 = 'text-multilingual-embedding-002'; + + /** + * @see https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api for various options + */ + public function __construct(string $name = self::GEMINI_EMBEDDING_001, array $options = []) + { + parent::__construct($name, [Capability::INPUT_TEXT, Capability::INPUT_MULTIPLE], $options); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Embeddings/ModelClient.php b/src/platform/src/Bridge/VertexAi/Embeddings/ModelClient.php new file mode 100644 index 00000000..81544ccc --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Embeddings/ModelClient.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Embeddings; + +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Junaid Farooq + */ +final readonly class ModelClient implements ModelClientInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $location, + private string $projectId, + ) { + } + + public function supports(BaseModel $model): bool + { + return $model instanceof Model; + } + + /** + * @throws TransportExceptionInterface + */ + public function request(BaseModel $model, array|string $payload, array $options = []): RawHttpResult + { + $url = \sprintf( + 'https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s', + $this->location, + $this->projectId, + $this->location, + $model->getName(), + 'predict', + ); + + $modelOptions = $model->getOptions(); + + $payload = [ + 'instances' => array_map( + static fn (string $text) => [ + 'content' => $text, + 'title' => $options['title'] ?? null, + 'task_type' => $modelOptions['task_type'] ?? TaskType::RETRIEVAL_QUERY, + ], + \is_array($payload) ? $payload : [$payload], + ), + ]; + + unset($modelOptions['task_type']); + + return new RawHttpResult( + $this->httpClient->request( + 'POST', + $url, + [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => array_merge($payload, $modelOptions), + ] + ) + ); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Embeddings/ResultConverter.php b/src/platform/src/Bridge/VertexAi/Embeddings/ResultConverter.php new file mode 100644 index 00000000..5e6d029f --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Embeddings/ResultConverter.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\VertexAi\Embeddings; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\VectorResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Vector\Vector; + +/** + * @author Junaid Farooq + */ +final readonly class ResultConverter implements ResultConverterInterface +{ + public function supports(BaseModel $model): bool + { + return $model instanceof Model; + } + + public function convert(RawResultInterface $result, array $options = []): VectorResult + { + $data = $result->getData(); + + if (isset($data['error'])) { + throw new RuntimeException(\sprintf('Error from Embeddings API: "%s"', $data['error']['message'] ?? 'Unknown error'), $data['error']['code']); + } + + if (!isset($data['predictions'])) { + throw new RuntimeException('Response does not contain data.'); + } + + return new VectorResult( + ...array_map( + static fn (array $item): Vector => new Vector($item['embeddings']['values']), + $data['predictions'], + ), + ); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Embeddings/TaskType.php b/src/platform/src/Bridge/VertexAi/Embeddings/TaskType.php new file mode 100644 index 00000000..2cc3b9cf --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Embeddings/TaskType.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\VertexAi\Embeddings; + +/** + * @author Junaid Farooq + */ +enum TaskType: string +{ + /** Used to generate embeddings that are optimized to classify texts according to preset labels. */ + public const CLASSIFICATION = 'CLASSIFICATION'; + + /** Used to generate embeddings that are optimized to cluster texts based on their similarities */ + public const CLUSTERING = 'CLUSTERING'; + + /** Specifies the given text is a document from the corpus being searched. */ + public const RETRIEVAL_DOCUMENT = 'RETRIEVAL_DOCUMENT'; + + /** Specifies the given text is a query in a search/retrieval setting. + * This is the recommended default for all the embeddings use case + * that do not align with a documented use case. + */ + public const RETRIEVAL_QUERY = 'RETRIEVAL_QUERY'; + + /** Specifies that the given text will be used for question answering. */ + public const QUESTION_ANSWERING = 'QUESTION_ANSWERING'; + + /** Specifies that the given text will be used for fact verification. */ + public const FACT_VERIFICATION = 'FACT_VERIFICATION'; + + /** Used to retrieve a code block based on a natural language query, + * such as sort an array or reverse a linked list. + * Embeddings of the code blocks are computed using RETRIEVAL_DOCUMENT. + */ + public const CODE_RETRIEVAL_QUERY = 'CODE_RETRIEVAL_QUERY'; + + /** Used to generate embeddings that are optimized to assess text similarity. + * This is not intended for retrieval use cases. + */ + public const SEMANTIC_SIMILARITY = 'SEMANTIC_SIMILARITY'; +} diff --git a/src/platform/src/Bridge/VertexAi/Gemini/Model.php b/src/platform/src/Bridge/VertexAi/Gemini/Model.php new file mode 100644 index 00000000..cd49f7eb --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Gemini/Model.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\VertexAi\Gemini; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model as BaseModel; + +/** + * @author Junaid Farooq + */ +final class Model extends BaseModel +{ + public const GEMINI_2_5_PRO = 'gemini-2.5-pro'; + public const GEMINI_2_5_FLASH = 'gemini-2.5-flash'; + public const GEMINI_2_0_FLASH = 'gemini-2.0-flash'; + public const GEMINI_2_5_FLASH_LITE = 'gemini-2.5-flash-lite'; + public const GEMINI_2_0_FLASH_LITE = 'gemini-2.0-flash-lite'; + + /** + * @param array $options The default options for the model usage + * + * @see https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference for more details + */ + public function __construct(string $name = self::GEMINI_2_5_PRO, array $options = []) + { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::INPUT_IMAGE, + Capability::INPUT_AUDIO, + Capability::INPUT_PDF, + Capability::OUTPUT_STREAMING, + Capability::OUTPUT_STRUCTURED, + Capability::TOOL_CALLING, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Gemini/ModelClient.php b/src/platform/src/Bridge/VertexAi/Gemini/ModelClient.php new file mode 100644 index 00000000..6879a5f6 --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Gemini/ModelClient.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\VertexAi\Gemini; + +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Junaid Farooq + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $location, + private string $projectId, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(BaseModel $model): bool + { + return $model instanceof Model; + } + + /** + * @throws TransportExceptionInterface When the HTTP request fails due to network issues + */ + public function request(BaseModel $model, array|string $payload, array $options = []): RawHttpResult + { + $url = \sprintf( + 'https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s', + $this->location, + $this->projectId, + $this->location, + $model->getName(), + $options['stream'] ?? false ? 'streamGenerateContent' : 'generateContent', + ); + + if (isset($options['response_format']['json_schema']['schema'])) { + $options['generationConfig']['responseMimeType'] = 'application/json'; + $options['generationConfig']['responseSchema'] = $options['response_format']['json_schema']['schema']; + + unset($options['response_format']); + } + + if (isset($options['generationConfig'])) { + $options['generationConfig'] = (object) $options['generationConfig']; + } + + if (isset($options['stream'])) { + $options['generation_config'] = (object) ($options['generationConfig'] ?? []); + + unset($options['generationConfig'], $options['stream']); + } + + if (isset($options['tools'])) { + $tools = $options['tools']; + + unset($options['tools']); + + $options['tools'][] = ['functionDeclarations' => $tools]; + } + + if (\is_string($payload)) { + $payload = [ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [ + ['text' => $payload], + ], + ], + ], + ]; + } + + return new RawHttpResult( + $this->httpClient->request( + 'POST', + $url, + [ + 'json' => array_merge($options, $payload), + ] + ) + ); + } +} diff --git a/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php b/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php new file mode 100644 index 00000000..f2b411a0 --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi\Gemini; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model as BaseModel; +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\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Junaid Farooq + */ +final readonly class ResultConverter implements ResultConverterInterface +{ + public function supports(BaseModel $model): bool + { + return $model instanceof Model; + } + + 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'])) { + throw new RuntimeException(\sprintf('Error from Gemini API: "%s"', $data['error']['message'] ?? 'Unknown error'), $data['error']['code']); + } + + if (!isset($data['candidates'][0]['content']['parts'][0])) { + throw new RuntimeException('Response does not contain any content.'); + } + + $choices = array_map($this->convertChoice(...), $data['candidates']); + + return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices); + } + + /** + * @throws TransportExceptionInterface + */ + private function convertStream(HttpResponse $result): \Generator + { + foreach ((new EventSourceHttpClient())->stream($result) as $chunk) { + if ($chunk->isFirst() || $chunk->isLast()) { + continue; + } + + $jsonDelta = trim($chunk->getContent()); + + if (str_starts_with($jsonDelta, '[') || str_starts_with($jsonDelta, ',')) { + $jsonDelta = substr($jsonDelta, 1); + } + + if (str_ends_with($jsonDelta, ']')) { + $jsonDelta = substr($jsonDelta, 0, -1); + } + + $deltas = explode(",\r\n", $jsonDelta); + + foreach ($deltas as $delta) { + if ('' === $delta) { + continue; + } + + try { + $data = json_decode($delta, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException('Failed to decode JSON response.', 0, $e); + } + + $choices = array_map($this->convertChoice(...), $data['candidates'] ?? []); + + if (!$choices) { + continue; + } + + if (1 !== \count($choices)) { + yield new ChoiceResult(...$choices); + continue; + } + + yield $choices[0]->getContent(); + } + } + } + + /** + * @param array{ + * finishReason?: string, + * content: array{ + * role: 'model', + * parts: array{ + * functionCall?: array{ + * name: string, + * args: mixed[] + * }, + * text?: string + * }[] + * } + * } $choices + */ + private function convertChoice(array $choices): ToolCallResult|TextResult + { + $content = $choices['content']['parts'][0] ?? []; + + if (isset($content['functionCall'])) { + return new ToolCallResult($this->convertToolCall($content['functionCall'])); + } + + if (isset($content['text'])) { + return new TextResult($content['text']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choices['finishReason'])); + } + + /** + * @param array{ + * name: string, + * args: mixed[] + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + return new ToolCall($toolCall['name'], $toolCall['name'], $toolCall['args']); + } +} diff --git a/src/platform/src/Bridge/VertexAi/PlatformFactory.php b/src/platform/src/Bridge/VertexAi/PlatformFactory.php new file mode 100644 index 00000000..7685cd9a --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/PlatformFactory.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi; + +use Google\Auth\ApplicationDefaultCredentials; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\GeminiContract; +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ModelClient as EmbeddingsModelClient; +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ResultConverter as EmbeddingsResultConverter; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\ModelClient as GeminiModelClient; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\ResultConverter as GeminiResultConverter; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Junaid Farooq + */ +final readonly class PlatformFactory +{ + public static function create( + string $location, + string $projectId, + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ): Platform { + if (!class_exists(ApplicationDefaultCredentials::class)) { + throw new RuntimeException('For using the Vertex AI platform, google/auth package is required for authentication via application default credentials. Try running "composer require google/auth".'); + } + + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new GeminiModelClient($httpClient, $location, $projectId), new EmbeddingsModelClient($httpClient, $location, $projectId)], + [new GeminiResultConverter(), new EmbeddingsResultConverter()], + $contract ?? GeminiContract::create(), + ); + } +} diff --git a/src/platform/src/Bridge/VertexAi/TokenOutputProcessor.php b/src/platform/src/Bridge/VertexAi/TokenOutputProcessor.php new file mode 100644 index 00000000..481f130e --- /dev/null +++ b/src/platform/src/Bridge/VertexAi/TokenOutputProcessor.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\VertexAi; + +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Result\Metadata\Metadata; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Junaid Farooq + */ +final class TokenOutputProcessor implements OutputProcessorInterface +{ + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + public function processOutput(Output $output): void + { + $metadata = $output->result->getMetadata(); + + if ($output->result instanceof StreamResult) { + $lastChunk = null; + + foreach ($output->result->getContent() as $chunk) { + // Store last event that contains usage metadata + if (isset($chunk['usageMetadata'])) { + $lastChunk = $chunk; + } + } + + if ($lastChunk) { + $this->extractUsageMetadata($lastChunk['usageMetadata'], $metadata); + } + + return; + } + + $rawResponse = $output->result->getRawResult()?->getObject(); + if (!$rawResponse instanceof ResponseInterface) { + return; + } + + $content = $rawResponse->toArray(false); + + if (!isset($content['usageMetadata'])) { + return; + } + + $this->extractUsageMetadata($content['usageMetadata'], $metadata); + } + + /** + * @param array{ + * promptTokenCount?: int, + * candidatesTokenCount?: int, + * thoughtsTokenCount?: int, + * totalTokenCount?: int + * } $usage + */ + private function extractUsageMetadata(array $usage, Metadata $metadata): void + { + $metadata->add('prompt_tokens', $usage['promptTokenCount'] ?? null); + $metadata->add('completion_tokens', $usage['candidatesTokenCount'] ?? null); + $metadata->add('thinking_tokens', $usage['thoughtsTokenCount'] ?? null); + $metadata->add('total_tokens', $usage['totalTokenCount'] ?? null); + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Contract/AssistantMessageNormalizerTest.php b/src/platform/tests/Bridge/VertexAi/Contract/AssistantMessageNormalizerTest.php new file mode 100644 index 00000000..aba754d0 --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Contract/AssistantMessageNormalizerTest.php @@ -0,0 +1,82 @@ + + * + * 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\VertexAi\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\Result\ToolCall; + +#[Small] +#[CoversClass(AssistantMessageNormalizer::class)] +#[UsesClass(Model::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(BaseModel::class)] +#[UsesClass(ToolCall::class)] +final class AssistantMessageNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new AssistantMessageNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new AssistantMessage('Hello'), context: [ + Contract::CONTEXT_MODEL => new Model(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not an assistant message')); + } + + public function testGetSupportedTypes() + { + $normalizer = new AssistantMessageNormalizer(); + + $this->assertSame([AssistantMessage::class => true], $normalizer->getSupportedTypes(null)); + } + + #[DataProvider('normalizeDataProvider')] + public function testNormalize(AssistantMessage $message, array $expectedOutput) + { + $normalizer = new AssistantMessageNormalizer(); + + $normalized = $normalizer->normalize($message); + + $this->assertSame($expectedOutput, $normalized); + } + + /** + * @return iterable + */ + public static function normalizeDataProvider(): iterable + { + yield 'assistant message' => [ + new AssistantMessage('Great to meet you. What would you like to know?'), + [['text' => 'Great to meet you. What would you like to know?']], + ]; + yield 'function call' => [ + new AssistantMessage(toolCalls: [new ToolCall('name1', 'name1', ['arg1' => '123'])]), + ['functionCall' => ['name' => 'name1', 'args' => ['arg1' => '123']]], + ]; + yield 'function call without parameters' => [ + new AssistantMessage(toolCalls: [new ToolCall('name1', 'name1')]), + ['functionCall' => ['name' => 'name1']], + ]; + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Contract/MessageBagNormalizerTest.php b/src/platform/tests/Bridge/VertexAi/Contract/MessageBagNormalizerTest.php new file mode 100644 index 00000000..608ef99f --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Contract/MessageBagNormalizerTest.php @@ -0,0 +1,152 @@ + + * + * 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\VertexAi\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[Medium] +#[CoversClass(MessageBagNormalizer::class)] +#[CoversClass(UserMessageNormalizer::class)] +#[CoversClass(AssistantMessageNormalizer::class)] +#[UsesClass(BaseModel::class)] +#[UsesClass(Model::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(AssistantMessage::class)] +final class MessageBagNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new MessageBagNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new MessageBag(), context: [ + Contract::CONTEXT_MODEL => new Model(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not a message bag')); + } + + public function testGetSupportedTypes() + { + $normalizer = new MessageBagNormalizer(); + + $expected = [ + MessageBag::class => true, + ]; + + $this->assertSame($expected, $normalizer->getSupportedTypes(null)); + } + + #[DataProvider('provideMessageBagData')] + public function testNormalize(MessageBag $bag, array $expected) + { + $normalizer = new MessageBagNormalizer(); + + // Set up the inner normalizers + $userMessageNormalizer = new UserMessageNormalizer(); + $assistantMessageNormalizer = new AssistantMessageNormalizer(); + + // Mock a normalizer that delegates to the appropriate concrete normalizer + $mockNormalizer = $this->createMock(NormalizerInterface::class); + $mockNormalizer->method('normalize') + ->willReturnCallback(function ($message) use ($userMessageNormalizer, $assistantMessageNormalizer): ?array { + if ($message instanceof UserMessage) { + return $userMessageNormalizer->normalize($message); + } + if ($message instanceof AssistantMessage) { + return $assistantMessageNormalizer->normalize($message); + } + + return null; + }); + + $normalizer->setNormalizer($mockNormalizer); + + $normalized = $normalizer->normalize($bag); + + $this->assertEquals($expected, $normalized); + } + + /** + * @return iterable + */ + public static function provideMessageBagData(): iterable + { + yield 'simple text' => [ + new MessageBag(Message::ofUser('Write a story about a magic backpack.')), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Write a story about a magic backpack.']]], + ], + ], + ]; + + yield 'text with image' => [ + new MessageBag( + Message::ofUser('Tell me about this instrument', Image::fromFile(\dirname(__DIR__, 6).'/fixtures/image.jpg')) + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [ + ['text' => 'Tell me about this instrument'], + ['inlineData' => ['mimeType' => 'image/jpeg', 'data' => base64_encode(file_get_contents(\dirname(__DIR__, 6).'/fixtures/image.jpg'))]], + ]], + ], + ], + ]; + + yield 'with assistant message' => [ + new MessageBag( + Message::ofUser('Hello'), + Message::ofAssistant('Great to meet you. What would you like to know?'), + Message::ofUser('I have two dogs in my house. How many paws are in my house?'), + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Hello']]], + ['role' => 'model', 'parts' => [['text' => 'Great to meet you. What would you like to know?']]], + ['role' => 'user', 'parts' => [['text' => 'I have two dogs in my house. How many paws are in my house?']]], + ], + ], + ]; + + yield 'with system messages' => [ + new MessageBag( + Message::forSystem('You are a cat. Your name is Neko.'), + Message::ofUser('Hello there'), + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Hello there']]], + ], + 'systemInstruction' => [ + 'parts' => [['text' => 'You are a cat. Your name is Neko.']], + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Contract/ToolCallMessageNormalizerTest.php b/src/platform/tests/Bridge/VertexAi/Contract/ToolCallMessageNormalizerTest.php new file mode 100644 index 00000000..0900f05d --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Contract/ToolCallMessageNormalizerTest.php @@ -0,0 +1,96 @@ + + * + * 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\VertexAi\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\Result\ToolCall; + +#[Small] +#[CoversClass(ToolCallMessageNormalizer::class)] +#[UsesClass(BaseModel::class)] +#[UsesClass(Model::class)] +#[UsesClass(ToolCallMessage::class)] +#[UsesClass(ToolCall::class)] +final class ToolCallMessageNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new ToolCallMessageNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new ToolCallMessage(new ToolCall('', '', []), ''), context: [ + Contract::CONTEXT_MODEL => new Model(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not a tool call')); + } + + public function testGetSupportedTypes() + { + $normalizer = new ToolCallMessageNormalizer(); + + $expected = [ + ToolCallMessage::class => true, + ]; + + $this->assertSame($expected, $normalizer->getSupportedTypes(null)); + } + + #[DataProvider('normalizeDataProvider')] + public function testNormalize(ToolCallMessage $message, array $expected) + { + $normalizer = new ToolCallMessageNormalizer(); + + $normalized = $normalizer->normalize($message); + + $this->assertEquals($expected, $normalized); + } + + /** + * @return iterable + */ + public static function normalizeDataProvider(): iterable + { + yield 'scalar' => [ + new ToolCallMessage( + new ToolCall('name1', 'name1', ['foo' => 'bar']), + 'true', + ), + [[ + 'functionResponse' => [ + 'name' => 'name1', + 'response' => ['rawResponse' => 'true'], + ], + ]], + ]; + + yield 'structured response' => [ + new ToolCallMessage( + new ToolCall('name1', 'name1', ['foo' => 'bar']), + '{"structured":"response"}', + ), + [[ + 'functionResponse' => [ + 'name' => 'name1', + 'response' => ['structured' => 'response'], + ], + ]], + ]; + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Contract/ToolNormalizerTest.php b/src/platform/tests/Bridge/VertexAi/Contract/ToolNormalizerTest.php new file mode 100644 index 00000000..96c2be25 --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Contract/ToolNormalizerTest.php @@ -0,0 +1,135 @@ + + * + * 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\VertexAi\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Fixtures\Tool\ToolNoParams; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\ToolNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Model as BaseModel; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[Small] +#[CoversClass(ToolNormalizer::class)] +#[UsesClass(BaseModel::class)] +#[UsesClass(Model::class)] +#[UsesClass(Tool::class)] +final class ToolNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new ToolNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new Tool(new ExecutionReference(ToolNoParams::class), 'test', 'test'), context: [ + Contract::CONTEXT_MODEL => new Model(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not a tool')); + } + + public function testGetSupportedTypes() + { + $normalizer = new ToolNormalizer(); + + $expected = [ + Tool::class => true, + ]; + + $this->assertSame($expected, $normalizer->getSupportedTypes(null)); + } + + #[DataProvider('normalizeDataProvider')] + public function testNormalize(Tool $tool, array $expected) + { + $normalizer = new ToolNormalizer(); + + $normalized = $normalizer->normalize($tool); + + $this->assertEquals($expected, $normalized); + } + + /** + * @return iterable + */ + public static function normalizeDataProvider(): iterable + { + yield 'call without params' => [ + new Tool( + new ExecutionReference(ToolNoParams::class, 'bar'), + 'tool_no_params', + 'A tool without parameters', + null, + ), + [ + 'description' => 'A tool without parameters', + 'name' => 'tool_no_params', + 'parameters' => null, + ], + ]; + + yield 'call with params' => [ + new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + 'A tool with required parameters', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'Text parameter', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'Number parameter', + ], + 'nestedObject' => [ + 'type' => 'object', + 'description' => 'bar', + 'additionalProperties' => false, + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ), + [ + 'description' => 'A tool with required parameters', + 'name' => 'tool_required_params', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'Text parameter', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'Number parameter', + ], + 'nestedObject' => [ + 'type' => 'object', + 'description' => 'bar', + ], + ], + 'required' => ['text', 'number'], + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Contract/UserMessageNormalizerTest.php b/src/platform/tests/Bridge/VertexAi/Contract/UserMessageNormalizerTest.php new file mode 100644 index 00000000..a640c359 --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Contract/UserMessageNormalizerTest.php @@ -0,0 +1,93 @@ + + * + * 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\VertexAi\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\Document; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; + +#[Small] +#[CoversClass(UserMessageNormalizer::class)] +#[UsesClass(Model::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(File::class)] +#[UsesClass(Image::class)] +#[UsesClass(Document::class)] +#[UsesClass(Audio::class)] +final class UserMessageNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new UserMessageNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new UserMessage(new Text('Hello')), context: [ + Contract::CONTEXT_MODEL => new Model(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not a user message')); + } + + public function testGetSupportedTypes() + { + $normalizer = new UserMessageNormalizer(); + + $this->assertSame([UserMessage::class => true], $normalizer->getSupportedTypes(null)); + } + + public function testNormalizeTextContent() + { + $normalizer = new UserMessageNormalizer(); + $message = new UserMessage(new Text('Write a story about a magic backpack.')); + + $normalized = $normalizer->normalize($message); + + $this->assertSame([['text' => 'Write a story about a magic backpack.']], $normalized); + } + + #[DataProvider('binaryContentProvider')] + public function testNormalizeBinaryContent(File $content, string $expectedMimeType, string $expectedPrefix) + { + $normalizer = new UserMessageNormalizer(); + $message = new UserMessage(new Text('Tell me about this instrument'), $content); + + $normalized = $normalizer->normalize($message); + + $this->assertCount(2, $normalized); + $this->assertSame(['text' => 'Tell me about this instrument'], $normalized[0]); + $this->assertArrayHasKey('inlineData', $normalized[1]); + $this->assertSame($expectedMimeType, $normalized[1]['inlineData']['mimeType']); + $this->assertNotEmpty($normalized[1]['inlineData']['data']); + + $this->assertStringStartsWith($expectedPrefix, $normalized[1]['inlineData']['data']); + } + + /** + * @return iterable + */ + public static function binaryContentProvider(): iterable + { + yield 'image' => [Image::fromFile(\dirname(__DIR__, 6).'/fixtures/image.jpg'), 'image/jpeg', '/9j/']; + yield 'document' => [Document::fromFile(\dirname(__DIR__, 6).'/fixtures/document.pdf'), 'application/pdf', 'JVBE']; + yield 'audio' => [Audio::fromFile(\dirname(__DIR__, 6).'/fixtures/audio.mp3'), 'audio/mpeg', 'SUQz']; + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Embeddings/ModelClientTest.php b/src/platform/tests/Bridge/VertexAi/Embeddings/ModelClientTest.php new file mode 100644 index 00000000..935520fc --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Embeddings/ModelClientTest.php @@ -0,0 +1,53 @@ + + * + * 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\VertexAi\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\VertexAi\Embeddings\Model; +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ModelClient; +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\TaskType; +use Symfony\AI\Platform\Result\VectorResult; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(ModelClient::class)] +#[Small] +#[UsesClass(Vector::class)] +#[UsesClass(VectorResult::class)] +#[UsesClass(Model::class)] +final class ModelClientTest extends TestCase +{ + public function testItGeneratesTheEmbeddingSuccessfully() + { + // Assert + $expectedResponse = [ + 'predictions' => [ + ['embeddings' => ['values' => [0.3, 0.4, 0.4]]], + ], + ]; + $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); + + $client = new ModelClient($httpClient, 'global', 'test'); + + $model = new Model(Model::GEMINI_EMBEDDING_001, ['outputDimensionality' => 1536, 'task_type' => TaskType::CLASSIFICATION]); + + // Act + $result = $client->request($model, 'test payload'); + + // Assert + $this->assertSame($expectedResponse, $result->getData()); + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Embeddings/ResultConverterTest.php b/src/platform/tests/Bridge/VertexAi/Embeddings/ResultConverterTest.php new file mode 100644 index 00000000..ca9ae1e4 --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Embeddings/ResultConverterTest.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\Tests\Bridge\VertexAi\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\VertexAi\Embeddings\Model; +use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ResultConverter; +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(Model::class)] +final class ResultConverterTest extends TestCase +{ + public function testItConvertsAResponseToAVectorResult() + { + // Assert + $expectedResponse = [ + 'predictions' => [ + ['embeddings' => ['values' => [0.3, 0.4, 0.4]]], + ['embeddings' => ['values' => [0.0, 0.0, 0.2]]], + ], + ]; + $result = $this->createStub(ResponseInterface::class); + $result + ->method('toArray') + ->willReturn($expectedResponse); + + $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()); + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Gemini/ModelClientTest.php b/src/platform/tests/Bridge/VertexAi/Gemini/ModelClientTest.php new file mode 100644 index 00000000..1b6447a8 --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Gemini/ModelClientTest.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\Tests\Bridge\VertexAi\Gemini; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\ModelClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[Small] +#[CoversClass(ModelClient::class)] +#[UsesClass(Model::class)] +final class ModelClientTest extends TestCase +{ + public function testItInvokesTheTextModelsSuccessfully() + { + // Arrange + $payload = [ + 'content' => [ + ['parts' => ['text' => 'Hello, world!']], + ], + ]; + $expectedResponse = [ + 'candidates' => [$payload], + ]; + $httpClient = new MockHttpClient( + new JsonMockResponse($expectedResponse), + ); + + $client = new ModelClient($httpClient, 'global', 'test'); + + // Act + $result = $client->request(new Model(Model::GEMINI_2_0_FLASH), $payload); + $data = $result->getData(); + $info = $result->getObject()->getInfo(); + + // Assert + $this->assertNotEmpty($data); + $this->assertNotEmpty($info); + $this->assertSame('POST', $info['http_method']); + $this->assertSame( + 'https://global-aiplatform.googleapis.com/v1/projects/test/locations/global/publishers/google/models/gemini-2.0-flash:generateContent', + $info['url'], + ); + $this->assertSame($expectedResponse, $data); + } +} diff --git a/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php b/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php new file mode 100644 index 00000000..0a7f0baa --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php @@ -0,0 +1,53 @@ + + * + * 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\VertexAi\Gemini; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; +use Symfony\AI\Platform\Bridge\VertexAi\Gemini\ResultConverter; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(ResultConverter::class)] +#[Small] +#[UsesClass(Model::class)] +final class ResultConverterTest extends TestCase +{ + public function testItConvertsAResponseToAVectorResult() + { + // Arrange + $payload = [ + 'content' => ['parts' => [['text' => 'Hello, world!']]], + ]; + $expectedResponse = [ + 'candidates' => [$payload], + ]; + $response = $this->createStub(ResponseInterface::class); + $response + ->method('toArray') + ->willReturn($expectedResponse); + + $resultConverter = new ResultConverter(); + + // Act + $result = $resultConverter->convert(new RawHttpResult($response)); + + // Assert + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello, world!', $result->getContent()); + } +} diff --git a/src/platform/tests/Bridge/VertexAi/TokenOutputProcessorTest.php b/src/platform/tests/Bridge/VertexAi/TokenOutputProcessorTest.php new file mode 100644 index 00000000..a73731ad --- /dev/null +++ b/src/platform/tests/Bridge/VertexAi/TokenOutputProcessorTest.php @@ -0,0 +1,123 @@ + + * + * 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\VertexAi; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Output; +use Symfony\AI\Platform\Bridge\VertexAi\TokenOutputProcessor; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\Metadata\Metadata; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(TokenOutputProcessor::class)] +#[UsesClass(Output::class)] +#[UsesClass(TextResult::class)] +#[UsesClass(StreamResult::class)] +#[UsesClass(Metadata::class)] +#[Small] +final class TokenOutputProcessorTest extends TestCase +{ + public function testItDoesNothingWithoutRawResponse() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + $output = $this->createOutput($textResult); + + $processor->processOutput($output); + + $this->assertCount(0, $output->result->getMetadata()); + } + + public function testItAddsUsageTokensToMetadata() + { + // Arrange + $textResult = new TextResult('test'); + + $rawResponse = $this->createRawResponse([ + 'usageMetadata' => [ + 'promptTokenCount' => 10, + 'candidatesTokenCount' => 20, + 'thoughtsTokenCount' => 20, + 'totalTokenCount' => 50, + ], + ]); + + $textResult->setRawResult($rawResponse); + $processor = new TokenOutputProcessor(); + $output = $this->createOutput($textResult); + + // Act + $processor->processOutput($output); + + // Assert + $metadata = $output->result->getMetadata(); + $this->assertCount(4, $metadata); + $this->assertSame(10, $metadata->get('prompt_tokens')); + $this->assertSame(20, $metadata->get('completion_tokens')); + $this->assertSame(20, $metadata->get('thinking_tokens')); + $this->assertSame(50, $metadata->get('total_tokens')); + } + + public function testItHandlesMissingUsageFields() + { + // Arrange + $textResult = new TextResult('test'); + + $rawResponse = $this->createRawResponse([ + 'usageMetadata' => [ + 'promptTokenCount' => 10, + ], + ]); + + $textResult->setRawResult($rawResponse); + $processor = new TokenOutputProcessor(); + $output = $this->createOutput($textResult); + + // Act + $processor->processOutput($output); + + // Assert + $metadata = $output->result->getMetadata(); + $this->assertCount(4, $metadata); + $this->assertSame(10, $metadata->get('prompt_tokens')); + $this->assertNull($metadata->get('completion_tokens')); + $this->assertNull($metadata->get('completion_tokens')); + $this->assertNull($metadata->get('total_tokens')); + } + + private function createRawResponse(array $data = []): RawHttpResult + { + $rawResponse = $this->createStub(ResponseInterface::class); + + $rawResponse->method('toArray')->willReturn($data); + + return new RawHttpResult($rawResponse); + } + + private function createOutput(ResultInterface $result): Output + { + return new Output( + $this->createStub(Model::class), + $result, + $this->createStub(MessageBagInterface::class), + [], + ); + } +}