symfony/ai-vertex-ai-platform
Google Vertex AI platform bridge for Symfony AI, enabling Gemini inference, text embeddings, and ADC authentication for Symfony applications.
Install the Package
composer require symfony/ai-vertex-ai-platform:^0.15
The bridge now requires symfony/ai v0.15+. Release 0.15.0 bumps every constraint in the monorepo to 0.15, so pin the bridge and the core package to the same minor — a mixed lock file (ai:0.14 + ai-vertex-ai-platform:0.15) will resolve but the wire-field mapping described below will not match the core's option normalizer.
Configure Authentication
Add Vertex AI credentials to config/packages/ai.yaml:
ai:
providers:
vertex_ai:
client: 'vertex_ai.client'
project_id: '%env(VERTEX_AI_PROJECT_ID)%'
location: 'us-central1'
auth:
# ADC (recommended for GCP environments)
adc: true
# OR API key
# api_key: '%env(VERTEX_AI_API_KEY)%'
First Use Case: Text Generation
Inject the GeminiClient and call a model. Note the generationConfig block — as of 0.15 the bridge expects its native wire field name (maxOutputTokens), not the canonical max_output_tokens:
use Symfony\AI\Gemini\GeminiClient;
use Symfony\AI\Gemini\Model\ChatCompletion;
class MyService {
public function __construct(private GeminiClient $gemini) {}
public function generateText(): string {
$response = $this->gemini->chatCompletion(
new ChatCompletion('gemini-1.5-flash-latest'),
[
'messages' => [['role' => 'user', 'content' => 'Hello!']],
'generationConfig' => ['maxOutputTokens' => 512],
]
);
return $response->getChoices()[0]->getMessage()->getContent();
}
}
Verify with a Test
use Symfony\AI\Test\MockGeminiClient;
$mockClient = new MockGeminiClient();
$mockClient->expects('chatCompletion')
->andReturn(new ChatCompletionResponse([new ChatChoice([
new ChatMessage('assistant', 'Hi there!')
])]));
Laravel note: the bridge is a Symfony bundle, so there is no service provider auto-discovery. Register the client yourself in
AppServiceProvider(or a dedicatedAiServiceProvider) and pull credentials fromconfig/services.php; everything below works unchanged once theGeminiClientis in Laravel's container.
use Symfony\AI\Provider\ProviderInterface;
class HybridAIService {
public function __construct(
private ProviderInterface $vertexProvider,
private ProviderInterface $openAIProvider
) {}
public function generate(string $prompt, bool $useFlashModel = false): string {
$provider = $useFlashModel ? $this->vertexProvider : $this->openAIProvider;
$response = $provider->chatCompletion(
new ChatCompletion($useFlashModel ? 'gemini-1.5-flash-latest' : 'gpt-4'),
[
'messages' => [['role' => 'user', 'content' => $prompt]],
'generationConfig' => ['maxOutputTokens' => 1024],
]
);
return $response->getChoices()[0]->getMessage()->getContent();
}
}
Because each bridge now translates its own wire field, keep option arrays bridge-specific. A shared "generic options" array passed to both providers is the fastest way to hit the rename gotcha below — build the option array after the provider is chosen.
use Symfony\AI\Gemini\Model\ChatCompletion;
use Symfony\AI\Streaming\StreamingResponse;
public function streamChatResponse(string $prompt): StreamingResponse {
$response = $this->gemini->chatCompletion(
new ChatCompletion('gemini-1.5-flash-latest'),
[
'messages' => [['role' => 'user', 'content' => $prompt]],
'generationConfig' => ['maxOutputTokens' => 2048],
],
['stream' => true]
);
return new StreamingResponse(function () use ($response) {
foreach ($response->getChoices()[0]->getMessage()->getContentDeltas() as $delta) {
yield $delta->getContent();
}
});
}
use Symfony\AI\Gemini\Model\Embedding;
public function generateEmbedding(string $text): array {
$response = $this->gemini->embedding(
new Embedding('text-embedding-004'),
['input' => [$text]]
);
return $response->getEmbeddings()[0]->getValues();
}
use Symfony\AI\Gemini\Model\MultipartChatCompletion;
public function analyzeDocument(string $filePath, string $prompt): string {
$response = $this->gemini->multipartChatCompletion(
new MultipartChatCompletion('gemini-1.5-flash-latest'),
[
'contents' => [
['mime_type' => 'application/pdf', 'data' => file_get_contents($filePath)],
['role' => 'user', 'parts' => [['text' => $prompt]]],
],
'generationConfig' => ['maxOutputTokens' => 4096],
]
);
return $response->getChoices()[0]->getMessage()->getContent();
}
With per-bridge wire fields, a single factory that returns the correct option shape is worth the 20 lines:
final class VertexGenerationOptions
{
public static function make(int $maxOutputTokens, float $temperature = 0.7): array
{
return [
'generationConfig' => [
'maxOutputTokens' => $maxOutputTokens,
'temperature' => $temperature,
],
];
}
}
Swap the factory (not every call site) when a future release renames a field again.
cloud-platform scope.ai:
providers:
vertex_ai:
auth:
api_key: '%env(VERTEX_AI_API_KEY)%'
try {
$response = $this->gemini->chatCompletion($model, $options);
} catch (\Symfony\AI\Exception\VertexAIException $e) {
$this->logger->error('Vertex AI error: ' . $e->getMessage(), ['model' => $model->getModelName()]);
throw new \RuntimeException('AI service unavailable', 0, $e);
}
# .env
VERTEX_AI_PROJECT_ID=your-project-id
VERTEX_AI_LOCATION=us-central1
VERTEX_AI_API_KEY=${VERTEX_AI_API_KEY:-}
# config/services.yaml
services:
Symfony\AI\Gemini\GeminiClient:
arguments:
$model: 'gemini-1.5-flash-latest'
$provider: '@ai.provider.vertex_ai'
$mockClient = new MockGeminiClient();
$mockClient->expects('chatCompletion')
->withArgs(function ($model, $args) {
return $model->getModelName() === 'gemini-1.5-flash-latest'
&& ($args['generationConfig']['maxOutputTokens'] ?? null) === 512;
})
->andReturn(new ChatCompletionResponse([new ChatChoice([
new ChatMessage('assistant', 'Mocked response!')
])]));
Asserting on the wire field is now the cheapest regression test you can add for the 0.15 rename.
max_output_tokens was renamed in 0.15 (breaking)Feature #2622 removes the canonical max_output_tokens option and makes each bridge emit its own wire field. For the Vertex/Gemini bridge that means maxOutputTokens inside generationConfig.
Before (≤ 0.14):
['max_output_tokens' => 1024]
After (0.15+):
['generationConfig' => ['maxOutputTokens' => 1024]]
The failure mode is quiet: an unrecognized key is generally not rejected by the Vertex API, so output just runs to the model default. Fix: grep your codebase for max_output_tokens, update every call site, and add an assertion on the returned usage/token count (or a mock withArgs check) so a silent fallback can't slip back in.
The constraint bump means the bridge and symfony/ai core must move together (composer update "symfony/ai*" -W). A partial update leaves the option normalizer and the wire-field mapper out of sync — the symptom is the same silent-default behaviour as above plus confusing Undefined array key notices.
GOOGLE_APPLICATION_CREDENTIALS or the Compute Engine metadata server.Vertex AI User (or Vertex AI Service Agent). Regenerate in GCP IAM.Vertex AI deprecates models (e.g. gemini-1.0). Use the catalog instead of hardcoding:
$models = $this->gemini->getModelCatalog()->getModels();
Multipart payloads cap out around 10MB. Compress or chunk:
$chunks = array_chunk(file_get_contents($filePath), 5_000_000);
text-embedding-004 accepts ~32K input tokens; Gemini output caps differ per model and are now expressed via maxOutputTokens. Truncate defensively before sending:
$text = mb_substr($longText, 0, 8000);
Deltas can end abruptly on connection drops — wrap with a timeout and retry:
$response = $this->gemini->chatCompletion(
new ChatCompletion('gemini-1.5-flash-latest'),
['messages' => [['role' => 'user', 'content' => $prompt]]],
['stream' => true, 'timeout' => 30]
);
Don't persist partial output until the stream closes cleanly.
ProviderInterface rather than subclassing GeminiClient — decorators survive bridge version bumps better.VertexGateway class so the option shape (generationConfig, wire field names) lives in one place.debug level when tuning token limits; it's the only reliable way to confirm the bridge actually emitted maxOutputTokens.How can I help you explore Laravel packages today?