From 3ff16bc3daeae2849dd7a2686b3f51c199a73737 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 21 Apr 2026 15:26:05 +0100 Subject: [PATCH 01/16] feat(genkit_vertexai): support Gemini and multimodal embedders --- .../genkit_vertexai/lib/src/embedders.dart | 411 ++++++++++++++++++ .../lib/src/vertex_api_client.dart | 77 +--- .../genkit_vertexai/test/embedders_test.dart | 142 ++++++ .../test/test_http_client.dart | 101 +++++ .../genkit_vertexai/test/vertex_test.dart | 34 +- 5 files changed, 666 insertions(+), 99 deletions(-) create mode 100644 packages/genkit_vertexai/lib/src/embedders.dart create mode 100644 packages/genkit_vertexai/test/embedders_test.dart create mode 100644 packages/genkit_vertexai/test/test_http_client.dart diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart new file mode 100644 index 00000000..dd42af75 --- /dev/null +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -0,0 +1,411 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:genkit/plugin.dart'; +import 'package:genkit_google_genai/common.dart'; +import 'package:genkit_google_genai/src/generated/generativelanguage.dart' + as gcl; + +List> listVertexEmbedders({ + required String pluginName, + required List publisherModels, +}) { + return publisherModels + .where((m) { + final modelMap = m as Map; + final name = modelMap['name'] as String?; + return name != null && name.contains('embedding'); + }) + .map((m) { + final modelMap = m as Map; + final modelName = (modelMap['name'] as String).split('/').last; + return embedderMetadata( + '$pluginName/$modelName', + customOptions: TextEmbedderOptions.$schema, + ); + }) + .toList(); +} + +Embedder createVertexEmbedder({ + required String pluginName, + required String embedderName, + required Future Function() getApiClient, + required GenkitException Function(Object, StackTrace) handleException, + required bool closeService, +}) { + return Embedder( + name: '$pluginName/$embedderName', + fn: (req, ctx) async { + if (req == null || req.input.isEmpty) { + return EmbedResponse(embeddings: []); + } + + final service = await getApiClient(); + try { + final options = req.options != null + ? TextEmbedderOptions.fromJson(req.options!) + : null; + + // Handle each input document separately, because Vertex embedders do + // not all use the same request shape. + final futures = req.input.map( + (doc) => _runEmbedderRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ), + ); + return EmbedResponse(embeddings: await Future.wait(futures)); + } catch (e, stack) { + throw handleException(e, stack); + } finally { + if (closeService) { + service.client.close(); + } + } + }, + ); +} + +String _documentText(DocumentData doc) { + return doc.content.where((p) => p.isText).map((p) => p.text).join('\n'); +} + +Future _runEmbedderRequest({ + required GenerativeLanguageBaseClient service, + required String embedderName, + required DocumentData doc, + required TextEmbedderOptions? options, +}) async { + // Choose the request style from the model family. + return switch (_requestShapeFor(embedderName)) { + _VertexEmbedderRequestShape.geminiEmbedding => _runGeminiEmbeddingRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ), + _VertexEmbedderRequestShape.multimodalPredict => + _runMultimodalPredictRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ), + _VertexEmbedderRequestShape.textPredict => _runTextPredictRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ), + }; +} + +Future _runGeminiEmbeddingRequest({ + required GenerativeLanguageBaseClient service, + required String embedderName, + required DocumentData doc, + required TextEmbedderOptions? options, +}) async { + try { + // Try the newer Gemini embedding API first. + return await _runEmbedContentRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ); + } on GenkitException catch (e) { + if (!_shouldFallbackToTextPredict(e)) { + rethrow; + } + } + + // Some older Gemini embedding models still need the predict API. + return _runTextPredictRequest( + service: service, + embedderName: embedderName, + doc: doc, + options: options, + ); +} + +Future _runEmbedContentRequest({ + required GenerativeLanguageBaseClient service, + required String embedderName, + required DocumentData doc, + required TextEmbedderOptions? options, +}) async { + final text = _documentText(doc); + final content = gcl.Content(parts: [gcl.Part(text: text)]); + final res = await service.embedContent( + gcl.EmbedContentRequest( + content: content, + outputDimensionality: options?.outputDimensionality, + taskType: options?.taskType, + title: options?.title, + ), + model: 'models/$embedderName', + ); + return Embedding(embedding: res.embedding?.values ?? []); +} + +Future _runMultimodalPredictRequest({ + required GenerativeLanguageBaseClient service, + required String embedderName, + required DocumentData doc, + required TextEmbedderOptions? options, +}) async { + // Multimodal embedders use a different predict request and response shape. + final instance = _toMultimodalInstance(doc); + final parameters = {}; + if (options?.outputDimensionality != null) { + parameters['dimension'] = options!.outputDimensionality; + } + + final res = await service.predict({ + 'instances': [instance.instance], + if (parameters.isNotEmpty) 'parameters': parameters, + }, model: 'models/$embedderName'); + + final predictions = res['predictions'] as List; + final prediction = predictions.single as Map; + return Embedding( + embedding: _multimodalPredictionEmbedding( + prediction, + expectedOutput: instance.expectedOutput, + ), + ); +} + +Future _runTextPredictRequest({ + required GenerativeLanguageBaseClient service, + required String embedderName, + required DocumentData doc, + required TextEmbedderOptions? options, +}) async { + // Older text embedders still use the predict payload shape. + final instance = {'content': _documentText(doc)}; + if (options?.title != null) { + instance['title'] = options!.title; + } + + final parameters = {}; + if (options?.outputDimensionality != null) { + parameters['outputDimensionality'] = options!.outputDimensionality; + } + if (options?.taskType != null) { + parameters['taskType'] = options!.taskType; + } + + final res = await service.predict({ + 'instances': [instance], + if (parameters.isNotEmpty) 'parameters': parameters, + }, model: 'models/$embedderName'); + + final predictions = res['predictions'] as List; + final prediction = predictions.single as Map; + final embeddingData = prediction['embeddings'] as Map; + final values = embeddingData['values'] as List; + return Embedding( + embedding: values.map((value) => (value as num).toDouble()).toList(), + ); +} + +_MultimodalInstance _toMultimodalInstance(DocumentData doc) { + final text = _documentText(doc).trim(); + final mediaParts = doc.content + .where((part) => part.isMedia) + .map((part) => part.media!) + .toList(); + + // A document can only contain one input type here. + // Text, image, and video use different embedding fields in the Vertex + // response, and this code needs one clear field to read for each document. + if (text.isNotEmpty && mediaParts.isNotEmpty) { + throw GenkitException( + 'Vertex multimodalembedding supports exactly one modality per input document in the embedder API. Provide text, one image, or one video.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + if (mediaParts.length > 1) { + throw GenkitException( + 'Vertex multimodalembedding supports at most one media part per input document in the embedder API.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + if (text.isNotEmpty) { + return _MultimodalInstance( + instance: {'text': text}, + expectedOutput: _MultimodalOutput.text, + ); + } + + if (mediaParts.isEmpty) { + throw GenkitException( + 'Vertex multimodalembedding requires text, image, or video input.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + final mediaField = _toMultimodalMediaField(mediaParts.single); + return _MultimodalInstance( + instance: {mediaField.key: mediaField.value}, + expectedOutput: mediaField.key == 'image' + ? _MultimodalOutput.image + : _MultimodalOutput.video, + ); +} + +MapEntry> _toMultimodalMediaField(Media media) { + final mimeType = _mediaMimeType(media); + final fieldName = _multimodalFieldName(mimeType); + + // Convert the media input into the format Vertex expects. + if (media.url.startsWith('data:')) { + final data = Uri.parse(media.url).data; + if (data == null) { + throw GenkitException( + 'Vertex multimodalembedding media inputs require a valid data URI.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + return MapEntry(fieldName, { + 'bytesBase64Encoded': base64Encode(data.contentAsBytes()), + if (mimeType != null && mimeType.isNotEmpty) 'mimeType': mimeType, + }); + } + + if (media.url.startsWith('gs://')) { + return MapEntry(fieldName, { + 'gcsUri': media.url, + if (mimeType != null && mimeType.isNotEmpty) 'mimeType': mimeType, + }); + } + + throw GenkitException( + 'Vertex multimodalembedding media inputs must use gs:// URIs or inline data URIs.', + status: StatusCodes.INVALID_ARGUMENT, + ); +} + +String? _mediaMimeType(Media media) { + if (media.contentType?.isNotEmpty == true) { + return media.contentType; + } + + if (media.url.startsWith('data:')) { + return Uri.parse(media.url).data?.mimeType; + } + + return null; +} + +String _multimodalFieldName(String? mimeType) { + if (mimeType == null || mimeType.isEmpty) { + throw GenkitException( + 'Vertex multimodalembedding media inputs require a MIME type.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + if (mimeType.startsWith('image/')) { + return 'image'; + } + if (mimeType.startsWith('video/')) { + return 'video'; + } + + throw GenkitException( + 'Unsupported Vertex multimodalembedding media MIME type: $mimeType', + status: StatusCodes.INVALID_ARGUMENT, + ); +} + +List _multimodalPredictionEmbedding( + Map prediction, { + required _MultimodalOutput expectedOutput, +}) { + // Read the embedding field that matches the input type. + final values = switch (expectedOutput) { + _MultimodalOutput.text => prediction['textEmbedding'] as List?, + _MultimodalOutput.image => prediction['imageEmbedding'] as List?, + _MultimodalOutput.video => + ((prediction['videoEmbeddings'] as List?)?.firstOrNull + as Map?)?['embedding'] + as List?, + }; + + if (values == null) { + throw GenkitException( + 'Vertex multimodalembedding did not return a ${expectedOutput.name} embedding.', + status: StatusCodes.INTERNAL, + ); + } + + return values.map((value) => (value as num).toDouble()).toList(); +} + +String _baseModelName(String modelName) { + final atIndex = modelName.indexOf('@'); + if (atIndex == -1) return modelName; + return modelName.substring(0, atIndex); +} + +_VertexEmbedderRequestShape _requestShapeFor(String modelName) { + final baseModelName = _baseModelName(modelName); + // Check the broad model families in order. + if (_isMultimodalEmbeddingFamily(baseModelName)) { + return _VertexEmbedderRequestShape.multimodalPredict; + } + if (_isGeminiEmbeddingFamily(baseModelName)) { + return _VertexEmbedderRequestShape.geminiEmbedding; + } + return _VertexEmbedderRequestShape.textPredict; +} + +bool _isMultimodalEmbeddingFamily(String modelName) { + return modelName.contains('multimodal') && modelName.contains('embedding'); +} + +bool _isGeminiEmbeddingFamily(String modelName) { + return modelName.startsWith('gemini-embedding-'); +} + +bool _shouldFallbackToTextPredict(GenkitException error) { + return error.status == StatusCodes.INVALID_ARGUMENT && + error.message.contains('not supported in the embedContent API'); +} + +class _MultimodalInstance { + final Map instance; + final _MultimodalOutput expectedOutput; + + _MultimodalInstance({required this.instance, required this.expectedOutput}); +} + +enum _MultimodalOutput { text, image, video } + +enum _VertexEmbedderRequestShape { + geminiEmbedding, + multimodalPredict, + textPredict, +} diff --git a/packages/genkit_vertexai/lib/src/vertex_api_client.dart b/packages/genkit_vertexai/lib/src/vertex_api_client.dart index dd0a5cf7..8c6318b3 100644 --- a/packages/genkit_vertexai/lib/src/vertex_api_client.dart +++ b/packages/genkit_vertexai/lib/src/vertex_api_client.dart @@ -19,6 +19,7 @@ import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'auth.dart'; +import 'embedders.dart'; @visibleForTesting class VertexAiPluginImpl extends CommonGoogleGenPlugin { @@ -107,20 +108,10 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { }) .toList(); - final embedders = publisherModels - .where((m) { - final modelMap = m as Map; - final name = modelMap['name'] as String?; - return name != null && - (name.contains('text-embedding-') || - name.contains('embedding-')); - }) - .map((m) { - final modelMap = m as Map; - final modelName = (modelMap['name'] as String).split('/').last; - return embedderMetadata('$name/$modelName'); - }) - .toList(); + final embedders = listVertexEmbedders( + pluginName: name, + publisherModels: publisherModels, + ); return [...models, ...embedders]; } catch (e, stack) { @@ -136,58 +127,12 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { @override Embedder createEmbedder(String embedderName) { - return Embedder( - name: '$name/$embedderName', - fn: (req, ctx) async { - if (req == null || req.input.isEmpty) { - return EmbedResponse(embeddings: []); - } - final service = await getApiClient(); - try { - final options = req.options != null - ? TextEmbedderOptions.fromJson(req.options!) - : null; - - final instances = req.input.map((doc) { - final text = doc.content - .where((p) => p.isText) - .map((p) => p.text) - .join('\n'); - return {'content': text}; - }).toList(); - - final parameters = {}; - if (options?.outputDimensionality != null) { - parameters['outputDimensionality'] = options!.outputDimensionality; - } - if (options?.taskType != null) { - parameters['taskType'] = options!.taskType; - } - - final res = await service.predict({ - 'instances': instances, - if (parameters.isNotEmpty) 'parameters': parameters, - }, model: 'models/$embedderName'); - - final predictions = res['predictions'] as List; - final embeddings = predictions.map((p) { - final emb = - (p as Map)['embeddings'] - as Map; - final vals = emb['values'] as List; - return Embedding( - embedding: vals.map((e) => (e as num).toDouble()).toList(), - ); - }).toList(); - return EmbedResponse(embeddings: embeddings); - } catch (e, stack) { - throw handleException(e, stack); - } finally { - if (authClient == null) { - service.client.close(); - } - } - }, + return createVertexEmbedder( + pluginName: name, + embedderName: embedderName, + getApiClient: () => getApiClient(), + handleException: handleException, + closeService: authClient == null, ); } } diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart new file mode 100644 index 00000000..84803418 --- /dev/null +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -0,0 +1,142 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:genkit/genkit.dart'; +import 'package:genkit_vertexai/src/vertex_api_client.dart'; +import 'package:test/test.dart'; + +import 'test_http_client.dart'; + +void main() { + group('Vertex AI Embedders', () { + test('lists embedders with schemas and custom options', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final actions = await plugin.list(); + final embedder = actions.firstWhere( + (action) => action.name == 'vertexai/text-embedding-005', + ); + + expect(embedder.name, 'vertexai/text-embedding-005'); + expect(embedder.inputSchema, same(EmbedRequest.$schema)); + expect(embedder.outputSchema, same(EmbedResponse.$schema)); + + final customOptions = + embedder.metadata['model']['customOptions'] as Map; + expect(customOptions['properties'], contains('outputDimensionality')); + expect(customOptions['properties'], contains('taskType')); + }); + + test('uses embedContent for embedders', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = + plugin.resolve('embedder', 'gemini-embedding-2-preview') as Action; + final req = EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + ); + + final response = await embedder.run(req); + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-2-preview:embedContent', + ); + expect(response.result.embeddings, hasLength(1)); + expect(response.result.embeddings.first.embedding, [0.1, 0.2, 0.3]); + }); + + test('uses predict for gemini-embedding-001', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = + plugin.resolve('embedder', 'gemini-embedding-001') as Action; + final req = EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + ); + + final response = await embedder.run(req); + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-001:predict', + ); + expect(response.result.embeddings, hasLength(1)); + expect(response.result.embeddings.first.embedding, [0.4, 0.5, 0.6]); + }); + + test( + 'uses multimodal predict schema for multimodalembedding text input', + () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = + plugin.resolve('embedder', 'multimodalembedding') as Action; + final req = EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + options: { + 'outputDimensionality': 256, + 'taskType': 'RETRIEVAL_DOCUMENT', + }, + ); + + final response = await embedder.run(req); + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/multimodalembedding:predict', + ); + + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances.single, {'text': 'hello'}); + expect(requestBody['parameters'], {'dimension': 256}); + expect(response.result.embeddings, hasLength(1)); + expect(response.result.embeddings.first.embedding, [0.7, 0.8, 0.9]); + }, + ); + }); +} diff --git a/packages/genkit_vertexai/test/test_http_client.dart b/packages/genkit_vertexai/test/test_http_client.dart new file mode 100644 index 00000000..32eba6fe --- /dev/null +++ b/packages/genkit_vertexai/test/test_http_client.dart @@ -0,0 +1,101 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class MockHttpClient extends http.BaseClient { + Uri? lastUrl; + String? lastBody; + + @override + Future send(http.BaseRequest request) async { + lastUrl = request.url; + if (request is http.Request) { + lastBody = request.body; + } + if (request.url.host == 'metadata.google.internal' || + request.url.host == 'oauth2.googleapis.com') { + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"access_token": "ya29.mock", "expires_in": 3600, "token_type": "Bearer"}', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/v1beta1/publishers/google/models') { + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"publisherModels": [{"name": "publishers/google/models/gemini-1.5-pro"}, {"name": "publishers/google/models/text-embedding-005"}, {"name": "publishers/google/models/multimodalembedding"}]}', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith('gemini-embedding-001:embedContent')) { + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"error": {"code": 400, "message": "Publisher Model `projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-001` is not supported in the embedContent API.", "status": "INVALID_ARGUMENT"}}', + ), + ), + 400, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith(':embedContent')) { + return http.StreamedResponse( + Stream.value(utf8.encode('{"embedding": {"values": [0.1, 0.2, 0.3]}}')), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.contains('multimodalembedding') && + request.url.path.endsWith(':predict')) { + return http.StreamedResponse( + Stream.value( + utf8.encode('{"predictions": [{"textEmbedding": [0.7, 0.8, 0.9]}]}'), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith(':predict')) { + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"predictions": [{"embeddings": {"values": [0.4, 0.5, 0.6]}}]}', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"candidates": [{"content": {"parts": [{"text": "response"}], "role": "model"}, "finishReason": "STOP"}]} ', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/packages/genkit_vertexai/test/vertex_test.dart b/packages/genkit_vertexai/test/vertex_test.dart index 99aba838..c7d96f16 100644 --- a/packages/genkit_vertexai/test/vertex_test.dart +++ b/packages/genkit_vertexai/test/vertex_test.dart @@ -12,43 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:convert'; - import 'package:genkit/genkit.dart'; - import 'package:genkit_vertexai/src/vertex_api_client.dart'; -import 'package:http/http.dart' as http; import 'package:test/test.dart'; -class MockHttpClient extends http.BaseClient { - Uri? lastUrl; - - @override - Future send(http.BaseRequest request) async { - lastUrl = request.url; - if (request.url.host == 'metadata.google.internal' || - request.url.host == 'oauth2.googleapis.com') { - return http.StreamedResponse( - Stream.value( - utf8.encode( - '{"access_token": "ya29.mock", "expires_in": 3600, "token_type": "Bearer"}', - ), - ), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.StreamedResponse( - Stream.value( - utf8.encode( - '{"candidates": [{"content": {"parts": [{"text": "response"}], "role": "model"}, "finishReason": "STOP"}]} ', - ), - ), - 200, - headers: {'content-type': 'application/json'}, - ); - } -} +import 'test_http_client.dart'; void main() { group('Vertex AI Plugin', () { From b955fdc37609022221bf65452cb21b58e1c1d978 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Wed, 22 Apr 2026 15:55:18 +0100 Subject: [PATCH 02/16] fix(genkit_vertexai): CI problems --- .../genkit_vertexai/lib/src/embedders.dart | 35 +++++++++---------- .../lib/src/vertex_api_client.dart | 2 +- .../genkit_vertexai/test/embedders_test.dart | 18 ++++++---- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index dd42af75..2bb6ad0b 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -15,9 +15,8 @@ import 'dart:convert'; import 'package:genkit/plugin.dart'; -import 'package:genkit_google_genai/common.dart'; -import 'package:genkit_google_genai/src/generated/generativelanguage.dart' - as gcl; +import 'package:genkit_google_genai/common.dart' as google; +import 'package:genkit_google_genai/generated.dart' as google_types; List> listVertexEmbedders({ required String pluginName, @@ -34,7 +33,7 @@ List> listVertexEmbedders({ final modelName = (modelMap['name'] as String).split('/').last; return embedderMetadata( '$pluginName/$modelName', - customOptions: TextEmbedderOptions.$schema, + customOptions: google.TextEmbedderOptions.$schema, ); }) .toList(); @@ -43,7 +42,7 @@ List> listVertexEmbedders({ Embedder createVertexEmbedder({ required String pluginName, required String embedderName, - required Future Function() getApiClient, + required Future Function() getApiClient, required GenkitException Function(Object, StackTrace) handleException, required bool closeService, }) { @@ -57,7 +56,7 @@ Embedder createVertexEmbedder({ final service = await getApiClient(); try { final options = req.options != null - ? TextEmbedderOptions.fromJson(req.options!) + ? google.TextEmbedderOptions.fromJson(req.options!) : null; // Handle each input document separately, because Vertex embedders do @@ -87,10 +86,10 @@ String _documentText(DocumentData doc) { } Future _runEmbedderRequest({ - required GenerativeLanguageBaseClient service, + required google.GenerativeLanguageBaseClient service, required String embedderName, required DocumentData doc, - required TextEmbedderOptions? options, + required google.TextEmbedderOptions? options, }) async { // Choose the request style from the model family. return switch (_requestShapeFor(embedderName)) { @@ -117,10 +116,10 @@ Future _runEmbedderRequest({ } Future _runGeminiEmbeddingRequest({ - required GenerativeLanguageBaseClient service, + required google.GenerativeLanguageBaseClient service, required String embedderName, required DocumentData doc, - required TextEmbedderOptions? options, + required google.TextEmbedderOptions? options, }) async { try { // Try the newer Gemini embedding API first. @@ -146,15 +145,15 @@ Future _runGeminiEmbeddingRequest({ } Future _runEmbedContentRequest({ - required GenerativeLanguageBaseClient service, + required google.GenerativeLanguageBaseClient service, required String embedderName, required DocumentData doc, - required TextEmbedderOptions? options, + required google.TextEmbedderOptions? options, }) async { final text = _documentText(doc); - final content = gcl.Content(parts: [gcl.Part(text: text)]); + final content = google_types.Content(parts: [google_types.Part(text: text)]); final res = await service.embedContent( - gcl.EmbedContentRequest( + google_types.EmbedContentRequest( content: content, outputDimensionality: options?.outputDimensionality, taskType: options?.taskType, @@ -166,10 +165,10 @@ Future _runEmbedContentRequest({ } Future _runMultimodalPredictRequest({ - required GenerativeLanguageBaseClient service, + required google.GenerativeLanguageBaseClient service, required String embedderName, required DocumentData doc, - required TextEmbedderOptions? options, + required google.TextEmbedderOptions? options, }) async { // Multimodal embedders use a different predict request and response shape. final instance = _toMultimodalInstance(doc); @@ -194,10 +193,10 @@ Future _runMultimodalPredictRequest({ } Future _runTextPredictRequest({ - required GenerativeLanguageBaseClient service, + required google.GenerativeLanguageBaseClient service, required String embedderName, required DocumentData doc, - required TextEmbedderOptions? options, + required google.TextEmbedderOptions? options, }) async { // Older text embedders still use the predict payload shape. final instance = {'content': _documentText(doc)}; diff --git a/packages/genkit_vertexai/lib/src/vertex_api_client.dart b/packages/genkit_vertexai/lib/src/vertex_api_client.dart index 8c6318b3..7717eb67 100644 --- a/packages/genkit_vertexai/lib/src/vertex_api_client.dart +++ b/packages/genkit_vertexai/lib/src/vertex_api_client.dart @@ -130,7 +130,7 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { return createVertexEmbedder( pluginName: name, embedderName: embedderName, - getApiClient: () => getApiClient(), + getApiClient: getApiClient, handleException: handleException, closeService: authClient == null, ); diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index 84803418..40688ddc 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -20,6 +20,12 @@ import 'package:test/test.dart'; import 'test_http_client.dart'; +typedef _EmbedderAction = Action; + +_EmbedderAction _resolveEmbedder(VertexAiPluginImpl plugin, String name) { + return plugin.resolve('embedder', name)! as _EmbedderAction; +} + void main() { group('Vertex AI Embedders', () { test('lists embedders with schemas and custom options', () async { @@ -39,8 +45,9 @@ void main() { expect(embedder.inputSchema, same(EmbedRequest.$schema)); expect(embedder.outputSchema, same(EmbedResponse.$schema)); + final modelMetadata = embedder.metadata['model'] as Map; final customOptions = - embedder.metadata['model']['customOptions'] as Map; + modelMetadata['customOptions'] as Map; expect(customOptions['properties'], contains('outputDimensionality')); expect(customOptions['properties'], contains('taskType')); }); @@ -53,8 +60,7 @@ void main() { authClient: mockClient, ); - final embedder = - plugin.resolve('embedder', 'gemini-embedding-2-preview') as Action; + final embedder = _resolveEmbedder(plugin, 'gemini-embedding-2-preview'); final req = EmbedRequest( input: [ DocumentData(content: [TextPart(text: 'hello')]), @@ -80,8 +86,7 @@ void main() { authClient: mockClient, ); - final embedder = - plugin.resolve('embedder', 'gemini-embedding-001') as Action; + final embedder = _resolveEmbedder(plugin, 'gemini-embedding-001'); final req = EmbedRequest( input: [ DocumentData(content: [TextPart(text: 'hello')]), @@ -109,8 +114,7 @@ void main() { authClient: mockClient, ); - final embedder = - plugin.resolve('embedder', 'multimodalembedding') as Action; + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); final req = EmbedRequest( input: [ DocumentData(content: [TextPart(text: 'hello')]), From ef67656649d41cc33c44710d36875b9979312486 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Wed, 22 Apr 2026 19:17:01 +0100 Subject: [PATCH 03/16] refactor(genkit_vertexai): replace google_types with google in embedders.dart --- packages/genkit_vertexai/lib/src/embedders.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 2bb6ad0b..0cfb1e3b 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -16,7 +16,6 @@ import 'dart:convert'; import 'package:genkit/plugin.dart'; import 'package:genkit_google_genai/common.dart' as google; -import 'package:genkit_google_genai/generated.dart' as google_types; List> listVertexEmbedders({ required String pluginName, @@ -151,9 +150,9 @@ Future _runEmbedContentRequest({ required google.TextEmbedderOptions? options, }) async { final text = _documentText(doc); - final content = google_types.Content(parts: [google_types.Part(text: text)]); + final content = google.Content(parts: [google.Part(text: text)]); final res = await service.embedContent( - google_types.EmbedContentRequest( + google.EmbedContentRequest( content: content, outputDimensionality: options?.outputDimensionality, taskType: options?.taskType, From 58f4df0e06a7cde0d2468fe1b862b4626392c82a Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Thu, 23 Apr 2026 04:51:24 +0100 Subject: [PATCH 04/16] fix(ci): resolve PR 12 failures --- packages/genkit/lib/src/ai/embedder.dart | 2 ++ packages/genkit_google_genai/lib/common.dart | 2 ++ .../firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/genkit/lib/src/ai/embedder.dart b/packages/genkit/lib/src/ai/embedder.dart index 9818cdcb..b5502e14 100644 --- a/packages/genkit/lib/src/ai/embedder.dart +++ b/packages/genkit/lib/src/ai/embedder.dart @@ -82,6 +82,8 @@ ActionMetadata embedderMetadata( name: name, description: name, actionType: 'embedder', + inputSchema: EmbedRequest.$schema, + outputSchema: EmbedResponse.$schema, metadata: { 'label': name, 'description': name, diff --git a/packages/genkit_google_genai/lib/common.dart b/packages/genkit_google_genai/lib/common.dart index 4ca02b8b..5824ce62 100644 --- a/packages/genkit_google_genai/lib/common.dart +++ b/packages/genkit_google_genai/lib/common.dart @@ -17,4 +17,6 @@ library; export 'src/api_client.dart'; export 'src/common_plugin.dart'; +export 'src/generated/generativelanguage.dart' + show Content, EmbedContentRequest, Part; export 'src/model.dart'; diff --git a/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift b/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift index 5ffdb1fa..524b2645 100644 --- a/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,7 +15,7 @@ import record_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FirebaseAIPlugin.register(with: registry.registrar(forPlugin: "FirebaseAIPlugin")) - FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) + FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) From 2c5966a625e9f2cb9fc8f69202ffa3a03d5fc7ff Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Thu, 23 Apr 2026 08:41:50 +0100 Subject: [PATCH 05/16] fix(ci): scope PR 12 fixes --- .github/workflows/flutter_ci.yml | 3 ++ packages/genkit/lib/src/ai/embedder.dart | 2 -- packages/genkit_google_genai/lib/common.dart | 2 -- .../genkit_vertexai/lib/src/embedders.dart | 29 +++++++++++++++---- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml index fb0b4a20..974ef6e4 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yml @@ -58,6 +58,9 @@ jobs: working-directory: testapps/flutter_genai run: flutter analyze + - name: Normalize generated macOS registrant + run: git restore testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift + - name: Verify clean git tree run: | git diff --exit-code diff --git a/packages/genkit/lib/src/ai/embedder.dart b/packages/genkit/lib/src/ai/embedder.dart index b5502e14..9818cdcb 100644 --- a/packages/genkit/lib/src/ai/embedder.dart +++ b/packages/genkit/lib/src/ai/embedder.dart @@ -82,8 +82,6 @@ ActionMetadata embedderMetadata( name: name, description: name, actionType: 'embedder', - inputSchema: EmbedRequest.$schema, - outputSchema: EmbedResponse.$schema, metadata: { 'label': name, 'description': name, diff --git a/packages/genkit_google_genai/lib/common.dart b/packages/genkit_google_genai/lib/common.dart index 5824ce62..4ca02b8b 100644 --- a/packages/genkit_google_genai/lib/common.dart +++ b/packages/genkit_google_genai/lib/common.dart @@ -17,6 +17,4 @@ library; export 'src/api_client.dart'; export 'src/common_plugin.dart'; -export 'src/generated/generativelanguage.dart' - show Content, EmbedContentRequest, Part; export 'src/model.dart'; diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 0cfb1e3b..29ddb6c3 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -16,6 +16,9 @@ import 'dart:convert'; import 'package:genkit/plugin.dart'; import 'package:genkit_google_genai/common.dart' as google; +// ignore: implementation_imports +import 'package:genkit_google_genai/src/generated/generativelanguage.dart' + as google_types; List> listVertexEmbedders({ required String pluginName, @@ -30,14 +33,28 @@ List> listVertexEmbedders({ .map((m) { final modelMap = m as Map; final modelName = (modelMap['name'] as String).split('/').last; - return embedderMetadata( - '$pluginName/$modelName', - customOptions: google.TextEmbedderOptions.$schema, - ); + return _vertexEmbedderMetadata('$pluginName/$modelName'); }) .toList(); } +ActionMetadata _vertexEmbedderMetadata( + String name, +) { + final metadata = embedderMetadata( + name, + customOptions: google.TextEmbedderOptions.$schema, + ); + return ActionMetadata( + name: metadata.name, + description: metadata.description, + actionType: metadata.actionType, + inputSchema: EmbedRequest.$schema, + outputSchema: EmbedResponse.$schema, + metadata: metadata.metadata, + ); +} + Embedder createVertexEmbedder({ required String pluginName, required String embedderName, @@ -150,9 +167,9 @@ Future _runEmbedContentRequest({ required google.TextEmbedderOptions? options, }) async { final text = _documentText(doc); - final content = google.Content(parts: [google.Part(text: text)]); + final content = google_types.Content(parts: [google_types.Part(text: text)]); final res = await service.embedContent( - google.EmbedContentRequest( + google_types.EmbedContentRequest( content: content, outputDimensionality: options?.outputDimensionality, taskType: options?.taskType, diff --git a/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift b/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift index 524b2645..5ffdb1fa 100644 --- a/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,7 +15,7 @@ import record_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FirebaseAIPlugin.register(with: registry.registrar(forPlugin: "FirebaseAIPlugin")) - FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) + FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) From f3cd2556ee34a08b17fbc61dd44a6f92273f9290 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Thu, 23 Apr 2026 15:27:00 +0100 Subject: [PATCH 06/16] fix(genkit_vertexai): address PR review feedback on embedders --- .github/workflows/flutter_ci.yml | 3 - .../lib/src/api_client.dart | 9 + .../genkit_vertexai/lib/src/embedders.dart | 222 +++++++++++------- .../genkit_vertexai/test/embedders_test.dart | 125 +++++++++- .../test/test_http_client.dart | 63 ++++- testapps/firebase_ai/pubspec.yaml | 1 + 6 files changed, 320 insertions(+), 103 deletions(-) diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml index 974ef6e4..fb0b4a20 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yml @@ -58,9 +58,6 @@ jobs: working-directory: testapps/flutter_genai run: flutter analyze - - name: Normalize generated macOS registrant - run: git restore testapps/firebase_ai/macos/Flutter/GeneratedPluginRegistrant.swift - - name: Verify clean git tree run: | git diff --exit-code diff --git a/packages/genkit_google_genai/lib/src/api_client.dart b/packages/genkit_google_genai/lib/src/api_client.dart index e667b98b..469e3ff9 100644 --- a/packages/genkit_google_genai/lib/src/api_client.dart +++ b/packages/genkit_google_genai/lib/src/api_client.dart @@ -40,6 +40,15 @@ class GenerativeLanguageBaseClient { return EmbedContentResponse.fromJson(res); } + Future batchEmbedContents( + BatchEmbedContentsRequest request, { + required String model, + }) async { + final url = '$apiUrlPrefix$model:batchEmbedContents'; + final res = await _call('POST', url, request.toJson()); + return BatchEmbedContentsResponse.fromJson(res); + } + Future generateContent( GenerateContentRequest request, { required String model, diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 29ddb6c3..64917b7f 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -75,17 +75,29 @@ Embedder createVertexEmbedder({ ? google.TextEmbedderOptions.fromJson(req.options!) : null; - // Handle each input document separately, because Vertex embedders do - // not all use the same request shape. - final futures = req.input.map( - (doc) => _runEmbedderRequest( + final embeddings = switch (_requestShapeFor(embedderName)) { + _VertexEmbedderRequestShape.geminiEmbedding => + _runGeminiEmbeddingRequests( + service: service, + embedderName: embedderName, + docs: req.input, + options: options, + ), + _VertexEmbedderRequestShape.multimodalPredict => + _runMultimodalPredictRequests( + service: service, + embedderName: embedderName, + docs: req.input, + options: options, + ), + _VertexEmbedderRequestShape.textPredict => _runTextPredictRequests( service: service, embedderName: embedderName, - doc: doc, + docs: req.input, options: options, ), - ); - return EmbedResponse(embeddings: await Future.wait(futures)); + }; + return EmbedResponse(embeddings: await embeddings); } catch (e, stack) { throw handleException(e, stack); } finally { @@ -101,63 +113,49 @@ String _documentText(DocumentData doc) { return doc.content.where((p) => p.isText).map((p) => p.text).join('\n'); } -Future _runEmbedderRequest({ +google_types.EmbedContentRequest _embedContentRequest( + DocumentData doc, + google.TextEmbedderOptions? options, +) { + final text = _documentText(doc); + return google_types.EmbedContentRequest( + content: google_types.Content(parts: [google_types.Part(text: text)]), + outputDimensionality: options?.outputDimensionality, + taskType: options?.taskType, + title: options?.title, + ); +} + +Future> _runGeminiEmbeddingRequests({ required google.GenerativeLanguageBaseClient service, required String embedderName, - required DocumentData doc, + required List docs, required google.TextEmbedderOptions? options, }) async { - // Choose the request style from the model family. - return switch (_requestShapeFor(embedderName)) { - _VertexEmbedderRequestShape.geminiEmbedding => _runGeminiEmbeddingRequest( - service: service, - embedderName: embedderName, - doc: doc, - options: options, - ), - _VertexEmbedderRequestShape.multimodalPredict => - _runMultimodalPredictRequest( + if (docs.length == 1) { + return [ + await _runEmbedContentRequest( service: service, embedderName: embedderName, - doc: doc, + doc: docs.single, options: options, ), - _VertexEmbedderRequestShape.textPredict => _runTextPredictRequest( - service: service, - embedderName: embedderName, - doc: doc, - options: options, - ), - }; -} - -Future _runGeminiEmbeddingRequest({ - required google.GenerativeLanguageBaseClient service, - required String embedderName, - required DocumentData doc, - required google.TextEmbedderOptions? options, -}) async { - try { - // Try the newer Gemini embedding API first. - return await _runEmbedContentRequest( - service: service, - embedderName: embedderName, - doc: doc, - options: options, - ); - } on GenkitException catch (e) { - if (!_shouldFallbackToTextPredict(e)) { - rethrow; - } + ]; } - // Some older Gemini embedding models still need the predict API. - return _runTextPredictRequest( - service: service, - embedderName: embedderName, - doc: doc, - options: options, + final res = await service.batchEmbedContents( + google_types.BatchEmbedContentsRequest( + requests: docs.map((doc) => _embedContentRequest(doc, options)).toList(), + ), + model: 'models/$embedderName', ); + final embeddings = _requireBatchEmbeddings( + res.embeddings, + expectedCount: docs.length, + ); + return embeddings + .map((embedding) => Embedding(embedding: embedding.values ?? const [])) + .toList(); } Future _runEmbedContentRequest({ @@ -166,59 +164,107 @@ Future _runEmbedContentRequest({ required DocumentData doc, required google.TextEmbedderOptions? options, }) async { - final text = _documentText(doc); - final content = google_types.Content(parts: [google_types.Part(text: text)]); final res = await service.embedContent( - google_types.EmbedContentRequest( - content: content, - outputDimensionality: options?.outputDimensionality, - taskType: options?.taskType, - title: options?.title, - ), + _embedContentRequest(doc, options), model: 'models/$embedderName', ); return Embedding(embedding: res.embedding?.values ?? []); } -Future _runMultimodalPredictRequest({ +List _requireBatchEmbeddings( + List? embeddings, { + required int expectedCount, +}) { + if (embeddings == null || embeddings.isEmpty) { + throw GenkitException( + 'Vertex AI returned no embeddings.', + status: StatusCodes.INTERNAL, + ); + } + if (embeddings.length != expectedCount) { + throw GenkitException( + 'Vertex AI returned ${embeddings.length} embeddings for $expectedCount input documents.', + status: StatusCodes.INTERNAL, + ); + } + return embeddings; +} + +List> _requirePredictions( + Object? rawPredictions, { + required int expectedCount, +}) { + if (rawPredictions is! List || rawPredictions.isEmpty) { + throw GenkitException( + 'Vertex AI returned no predictions.', + status: StatusCodes.INTERNAL, + ); + } + if (rawPredictions.length != expectedCount) { + throw GenkitException( + 'Vertex AI returned ${rawPredictions.length} predictions for $expectedCount input documents.', + status: StatusCodes.INTERNAL, + ); + } + + return rawPredictions.map((prediction) { + if (prediction is! Map) { + throw GenkitException( + 'Vertex AI returned an invalid prediction payload.', + status: StatusCodes.INTERNAL, + ); + } + return prediction; + }).toList(); +} + +Future> _runMultimodalPredictRequests({ required google.GenerativeLanguageBaseClient service, required String embedderName, - required DocumentData doc, + required List docs, required google.TextEmbedderOptions? options, }) async { // Multimodal embedders use a different predict request and response shape. - final instance = _toMultimodalInstance(doc); + final instances = docs.map(_toMultimodalInstance).toList(); final parameters = {}; if (options?.outputDimensionality != null) { parameters['dimension'] = options!.outputDimensionality; } final res = await service.predict({ - 'instances': [instance.instance], + 'instances': instances.map((instance) => instance.instance).toList(), if (parameters.isNotEmpty) 'parameters': parameters, }, model: 'models/$embedderName'); - final predictions = res['predictions'] as List; - final prediction = predictions.single as Map; - return Embedding( - embedding: _multimodalPredictionEmbedding( - prediction, - expectedOutput: instance.expectedOutput, - ), + final predictions = _requirePredictions( + res['predictions'], + expectedCount: instances.length, ); + return [ + for (var i = 0; i < predictions.length; i++) + Embedding( + embedding: _multimodalPredictionEmbedding( + predictions[i], + expectedOutput: instances[i].expectedOutput, + ), + ), + ]; } -Future _runTextPredictRequest({ +Future> _runTextPredictRequests({ required google.GenerativeLanguageBaseClient service, required String embedderName, - required DocumentData doc, + required List docs, required google.TextEmbedderOptions? options, }) async { // Older text embedders still use the predict payload shape. - final instance = {'content': _documentText(doc)}; - if (options?.title != null) { - instance['title'] = options!.title; - } + final instances = docs.map((doc) { + final instance = {'content': _documentText(doc)}; + if (options?.title != null) { + instance['title'] = options!.title; + } + return instance; + }).toList(); final parameters = {}; if (options?.outputDimensionality != null) { @@ -229,12 +275,18 @@ Future _runTextPredictRequest({ } final res = await service.predict({ - 'instances': [instance], + 'instances': instances, if (parameters.isNotEmpty) 'parameters': parameters, }, model: 'models/$embedderName'); - final predictions = res['predictions'] as List; - final prediction = predictions.single as Map; + final predictions = _requirePredictions( + res['predictions'], + expectedCount: docs.length, + ); + return predictions.map(_textPredictionEmbedding).toList(); +} + +Embedding _textPredictionEmbedding(Map prediction) { final embeddingData = prediction['embeddings'] as Map; final values = embeddingData['values'] as List; return Embedding( @@ -391,6 +443,9 @@ _VertexEmbedderRequestShape _requestShapeFor(String modelName) { if (_isMultimodalEmbeddingFamily(baseModelName)) { return _VertexEmbedderRequestShape.multimodalPredict; } + if (_usesLegacyGeminiPredictApi(baseModelName)) { + return _VertexEmbedderRequestShape.textPredict; + } if (_isGeminiEmbeddingFamily(baseModelName)) { return _VertexEmbedderRequestShape.geminiEmbedding; } @@ -405,9 +460,8 @@ bool _isGeminiEmbeddingFamily(String modelName) { return modelName.startsWith('gemini-embedding-'); } -bool _shouldFallbackToTextPredict(GenkitException error) { - return error.status == StatusCodes.INVALID_ARGUMENT && - error.message.contains('not supported in the embedContent API'); +bool _usesLegacyGeminiPredictApi(String modelName) { + return modelName == 'gemini-embedding-001'; } class _MultimodalInstance { diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index 40688ddc..fc59a312 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -52,7 +52,7 @@ void main() { expect(customOptions['properties'], contains('taskType')); }); - test('uses embedContent for embedders', () async { + test('uses embedContent for a single Gemini preview input', () async { final mockClient = MockHttpClient(); final plugin = VertexAiPluginImpl( projectId: 'my-project', @@ -78,7 +78,7 @@ void main() { expect(response.result.embeddings.first.embedding, [0.1, 0.2, 0.3]); }); - test('uses predict for gemini-embedding-001', () async { + test('uses batchEmbedContents for multiple Gemini preview inputs', () async { final mockClient = MockHttpClient(); final plugin = VertexAiPluginImpl( projectId: 'my-project', @@ -86,10 +86,11 @@ void main() { authClient: mockClient, ); - final embedder = _resolveEmbedder(plugin, 'gemini-embedding-001'); + final embedder = _resolveEmbedder(plugin, 'gemini-embedding-2-preview'); final req = EmbedRequest( input: [ DocumentData(content: [TextPart(text: 'hello')]), + DocumentData(content: [TextPart(text: 'world')]), ], ); @@ -98,14 +99,60 @@ void main() { expect(mockClient.lastUrl, isNotNull); expect( mockClient.lastUrl.toString(), - 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-001:predict', + 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-2-preview:batchEmbedContents', ); - expect(response.result.embeddings, hasLength(1)); - expect(response.result.embeddings.first.embedding, [0.4, 0.5, 0.6]); + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final requests = requestBody['requests'] as List; + expect(requests, hasLength(2)); + expect(response.result.embeddings, hasLength(2)); + expect(response.result.embeddings[0].embedding, [0.1, 0.2, 0.3]); + expect(response.result.embeddings[1].embedding, [1.1, 1.2, 1.3]); }); test( - 'uses multimodal predict schema for multimodalembedding text input', + 'uses predict directly for legacy gemini-embedding-001 inputs', + () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'gemini-embedding-001'); + final req = EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + DocumentData(content: [TextPart(text: 'world')]), + ], + ); + + final response = await embedder.run(req); + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-embedding-001:predict', + ); + expect( + mockClient.requestUrls.where( + (url) => url.path.endsWith('gemini-embedding-001:embedContent'), + ), + isEmpty, + ); + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances, hasLength(2)); + expect(response.result.embeddings, hasLength(2)); + expect(response.result.embeddings[0].embedding, [0.4, 0.5, 0.6]); + expect(response.result.embeddings[1].embedding, [1.4, 1.5, 1.6]); + }, + ); + + test( + 'uses multimodal predict schema for text-only multimodal inputs', () async { final mockClient = MockHttpClient(); final plugin = VertexAiPluginImpl( @@ -142,5 +189,69 @@ void main() { expect(response.result.embeddings.first.embedding, [0.7, 0.8, 0.9]); }, ); + + test( + 'throws a descriptive error when Vertex returns no predictions', + () async { + final mockClient = MockHttpClient(returnEmptyPredictions: true); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'gemini-embedding-001'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Vertex AI returned no predictions.'), + ), + ), + ); + }, + ); + + test( + 'throws when a multimodal response omits the expected embedding field', + () async { + final mockClient = MockHttpClient( + returnMissingMultimodalEmbedding: true, + ); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('did not return a text embedding'), + ), + ), + ); + }, + ); }); } diff --git a/packages/genkit_vertexai/test/test_http_client.dart b/packages/genkit_vertexai/test/test_http_client.dart index 32eba6fe..ef4de098 100644 --- a/packages/genkit_vertexai/test/test_http_client.dart +++ b/packages/genkit_vertexai/test/test_http_client.dart @@ -17,14 +17,26 @@ import 'dart:convert'; import 'package:http/http.dart' as http; class MockHttpClient extends http.BaseClient { + MockHttpClient({ + this.returnEmptyPredictions = false, + this.returnMissingMultimodalEmbedding = false, + }); + + final bool returnEmptyPredictions; + final bool returnMissingMultimodalEmbedding; + final List requestUrls = []; + final List requestBodies = []; Uri? lastUrl; String? lastBody; @override Future send(http.BaseRequest request) async { + requestUrls.add(request.url); lastUrl = request.url; + final requestBody = request is http.Request ? request.body : null; if (request is http.Request) { - lastBody = request.body; + lastBody = requestBody; + requestBodies.add(requestBody!); } if (request.url.host == 'metadata.google.internal' || request.url.host == 'oauth2.googleapis.com') { @@ -60,6 +72,21 @@ class MockHttpClient extends http.BaseClient { headers: {'content-type': 'application/json'}, ); } + if (request.url.path.endsWith(':batchEmbedContents')) { + final body = jsonDecode(requestBody!) as Map; + final requests = body['requests'] as List; + final embeddings = List.generate( + requests.length, + (index) => { + 'values': [index + 0.1, index + 0.2, index + 0.3], + }, + ); + return http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'embeddings': embeddings}))), + 200, + headers: {'content-type': 'application/json'}, + ); + } if (request.url.path.endsWith(':embedContent')) { return http.StreamedResponse( Stream.value(utf8.encode('{"embedding": {"values": [0.1, 0.2, 0.3]}}')), @@ -69,21 +96,39 @@ class MockHttpClient extends http.BaseClient { } if (request.url.path.contains('multimodalembedding') && request.url.path.endsWith(':predict')) { + final body = jsonDecode(requestBody!) as Map; + final instances = body['instances'] as List; + final predictions = returnEmptyPredictions + ? const [] + : List.generate( + instances.length, + (index) => returnMissingMultimodalEmbedding + ? {} + : { + 'textEmbedding': [index + 0.7, index + 0.8, index + 0.9], + }, + ); return http.StreamedResponse( - Stream.value( - utf8.encode('{"predictions": [{"textEmbedding": [0.7, 0.8, 0.9]}]}'), - ), + Stream.value(utf8.encode(jsonEncode({'predictions': predictions}))), 200, headers: {'content-type': 'application/json'}, ); } if (request.url.path.endsWith(':predict')) { + final body = jsonDecode(requestBody!) as Map; + final instances = body['instances'] as List; + final predictions = returnEmptyPredictions + ? const [] + : List.generate( + instances.length, + (index) => { + 'embeddings': { + 'values': [index + 0.4, index + 0.5, index + 0.6], + }, + }, + ); return http.StreamedResponse( - Stream.value( - utf8.encode( - '{"predictions": [{"embeddings": {"values": [0.4, 0.5, 0.6]}}]}', - ), - ), + Stream.value(utf8.encode(jsonEncode({'predictions': predictions}))), 200, headers: {'content-type': 'application/json'}, ); diff --git a/testapps/firebase_ai/pubspec.yaml b/testapps/firebase_ai/pubspec.yaml index 17c65f2e..49a4a73e 100644 --- a/testapps/firebase_ai/pubspec.yaml +++ b/testapps/firebase_ai/pubspec.yaml @@ -35,6 +35,7 @@ dev_dependencies: sdk: flutter dependency_overrides: + firebase_app_check: 0.4.1+4 genkit: path: ../../packages/genkit genkit_firebase_ai: From fa49544ebe673df6c98bea15262c9a98845805a5 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 27 Apr 2026 16:53:09 +0100 Subject: [PATCH 07/16] chore(firebase_ai): remove firebase_app_check dependency --- testapps/firebase_ai/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/testapps/firebase_ai/pubspec.yaml b/testapps/firebase_ai/pubspec.yaml index 49a4a73e..17c65f2e 100644 --- a/testapps/firebase_ai/pubspec.yaml +++ b/testapps/firebase_ai/pubspec.yaml @@ -35,7 +35,6 @@ dev_dependencies: sdk: flutter dependency_overrides: - firebase_app_check: 0.4.1+4 genkit: path: ../../packages/genkit genkit_firebase_ai: From 45773e66ae6319817c11479869a7cf6b5d73c31c Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Sun, 10 May 2026 00:32:20 +0100 Subject: [PATCH 08/16] feat(embedders): add task_type option to text predict requests and update tests --- .../genkit_vertexai/lib/src/embedders.dart | 6 ++-- .../genkit_vertexai/test/embedders_test.dart | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 64917b7f..fb63bc02 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -263,6 +263,9 @@ Future> _runTextPredictRequests({ if (options?.title != null) { instance['title'] = options!.title; } + if (options?.taskType != null) { + instance['task_type'] = options!.taskType; + } return instance; }).toList(); @@ -270,9 +273,6 @@ Future> _runTextPredictRequests({ if (options?.outputDimensionality != null) { parameters['outputDimensionality'] = options!.outputDimensionality; } - if (options?.taskType != null) { - parameters['taskType'] = options!.taskType; - } final res = await service.predict({ 'instances': instances, diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index fc59a312..50694c74 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -151,6 +151,39 @@ void main() { }, ); + test('uses text predict REST option schema', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'text-embedding-005'); + final req = EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + options: { + 'outputDimensionality': 256, + 'taskType': 'RETRIEVAL_DOCUMENT', + 'title': 'document title', + }, + ); + + await embedder.run(req); + + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances.single, { + 'content': 'hello', + 'task_type': 'RETRIEVAL_DOCUMENT', + 'title': 'document title', + }); + expect(requestBody['parameters'], {'outputDimensionality': 256}); + }); + test( 'uses multimodal predict schema for text-only multimodal inputs', () async { From f3d989facc8cfa8997d42c743e7ff4ee6c554fc9 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 13:00:36 +0100 Subject: [PATCH 09/16] feat(genkit_vertexai): flatten multimodal embeddings with source metadata --- .../genkit_vertexai/lib/src/embedders.dart | 172 +++++++++++++----- .../genkit_vertexai/test/embedders_test.dart | 71 ++++++++ .../test/test_http_client.dart | 27 ++- 3 files changed, 214 insertions(+), 56 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index fb63bc02..3acd4843 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -225,7 +225,10 @@ Future> _runMultimodalPredictRequests({ required google.TextEmbedderOptions? options, }) async { // Multimodal embedders use a different predict request and response shape. - final instances = docs.map(_toMultimodalInstance).toList(); + final instances = [ + for (var i = 0; i < docs.length; i++) + _toMultimodalInstance(docs[i], documentIndex: i), + ]; final parameters = {}; if (options?.outputDimensionality != null) { parameters['dimension'] = options!.outputDimensionality; @@ -242,11 +245,9 @@ Future> _runMultimodalPredictRequests({ ); return [ for (var i = 0; i < predictions.length; i++) - Embedding( - embedding: _multimodalPredictionEmbedding( - predictions[i], - expectedOutput: instances[i].expectedOutput, - ), + ..._multimodalPredictionEmbeddings( + predictions[i], + expectedOutputs: instances[i].expectedOutputs, ), ]; } @@ -294,50 +295,70 @@ Embedding _textPredictionEmbedding(Map prediction) { ); } -_MultimodalInstance _toMultimodalInstance(DocumentData doc) { +_MultimodalInstance _toMultimodalInstance( + DocumentData doc, { + required int documentIndex, +}) { final text = _documentText(doc).trim(); - final mediaParts = doc.content - .where((part) => part.isMedia) - .map((part) => part.media!) - .toList(); + final instance = {}; + final expectedOutputs = <_MultimodalExpectedOutput>[]; - // A document can only contain one input type here. - // Text, image, and video use different embedding fields in the Vertex - // response, and this code needs one clear field to read for each document. - if (text.isNotEmpty && mediaParts.isNotEmpty) { - throw GenkitException( - 'Vertex multimodalembedding supports exactly one modality per input document in the embedder API. Provide text, one image, or one video.', - status: StatusCodes.INVALID_ARGUMENT, + if (text.isNotEmpty) { + instance['text'] = text; + expectedOutputs.add( + _MultimodalExpectedOutput( + output: _MultimodalOutput.text, + metadata: { + 'documentIndex': documentIndex, + 'modality': 'text', + 'partIndices': [ + for (var i = 0; i < doc.content.length; i++) + if (doc.content[i].isText && + (doc.content[i].text?.trim().isNotEmpty ?? false)) + i, + ], + }, + ), ); } - if (mediaParts.length > 1) { - throw GenkitException( - 'Vertex multimodalembedding supports at most one media part per input document in the embedder API.', - status: StatusCodes.INVALID_ARGUMENT, - ); - } + for (var i = 0; i < doc.content.length; i++) { + final part = doc.content[i]; + if (!part.isMedia) continue; - if (text.isNotEmpty) { - return _MultimodalInstance( - instance: {'text': text}, - expectedOutput: _MultimodalOutput.text, + final mediaField = _toMultimodalMediaField(part.media!); + if (instance.containsKey(mediaField.key)) { + throw GenkitException( + 'Vertex multimodalembedding supports at most one ${mediaField.key} part per input document.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + instance[mediaField.key] = mediaField.value; + expectedOutputs.add( + _MultimodalExpectedOutput( + output: mediaField.key == 'image' + ? _MultimodalOutput.image + : _MultimodalOutput.video, + metadata: { + 'documentIndex': documentIndex, + 'modality': mediaField.key, + 'partIndex': i, + }, + ), ); } - if (mediaParts.isEmpty) { + if (instance.isEmpty) { throw GenkitException( 'Vertex multimodalembedding requires text, image, or video input.', status: StatusCodes.INVALID_ARGUMENT, ); } - final mediaField = _toMultimodalMediaField(mediaParts.single); return _MultimodalInstance( - instance: {mediaField.key: mediaField.value}, - expectedOutput: mediaField.key == 'image' - ? _MultimodalOutput.image - : _MultimodalOutput.video, + instance: instance, + expectedOutputs: expectedOutputs, ); } @@ -407,28 +428,74 @@ String _multimodalFieldName(String? mimeType) { ); } -List _multimodalPredictionEmbedding( +List _multimodalPredictionEmbeddings( Map prediction, { - required _MultimodalOutput expectedOutput, + required List<_MultimodalExpectedOutput> expectedOutputs, }) { - // Read the embedding field that matches the input type. - final values = switch (expectedOutput) { - _MultimodalOutput.text => prediction['textEmbedding'] as List?, - _MultimodalOutput.image => prediction['imageEmbedding'] as List?, - _MultimodalOutput.video => - ((prediction['videoEmbeddings'] as List?)?.firstOrNull - as Map?)?['embedding'] - as List?, - }; + final embeddings = []; + for (final expectedOutput in expectedOutputs) { + switch (expectedOutput.output) { + case _MultimodalOutput.text: + embeddings.add( + _embeddingFromMultimodalValues( + prediction['textEmbedding'] as List?, + expectedOutput: expectedOutput, + ), + ); + case _MultimodalOutput.image: + embeddings.add( + _embeddingFromMultimodalValues( + prediction['imageEmbedding'] as List?, + expectedOutput: expectedOutput, + ), + ); + case _MultimodalOutput.video: + final videoEmbeddings = prediction['videoEmbeddings'] as List?; + if (videoEmbeddings == null || videoEmbeddings.isEmpty) { + throw GenkitException( + 'Vertex multimodalembedding did not return a video embedding.', + status: StatusCodes.INTERNAL, + ); + } + for (var i = 0; i < videoEmbeddings.length; i++) { + final videoEmbedding = videoEmbeddings[i] as Map; + embeddings.add( + _embeddingFromMultimodalValues( + videoEmbedding['embedding'] as List?, + expectedOutput: expectedOutput, + metadata: { + ...expectedOutput.metadata, + 'segmentIndex': i, + if (videoEmbedding['startOffsetSec'] != null) + 'startOffsetSec': videoEmbedding['startOffsetSec'], + if (videoEmbedding['endOffsetSec'] != null) + 'endOffsetSec': videoEmbedding['endOffsetSec'], + }, + ), + ); + } + } + } + return embeddings; +} + +Embedding _embeddingFromMultimodalValues( + List? values, { + required _MultimodalExpectedOutput expectedOutput, + Map? metadata, +}) { if (values == null) { throw GenkitException( - 'Vertex multimodalembedding did not return a ${expectedOutput.name} embedding.', + 'Vertex multimodalembedding did not return a ${expectedOutput.output.name} embedding.', status: StatusCodes.INTERNAL, ); } - return values.map((value) => (value as num).toDouble()).toList(); + return Embedding( + embedding: values.map((value) => (value as num).toDouble()).toList(), + metadata: metadata ?? expectedOutput.metadata, + ); } String _baseModelName(String modelName) { @@ -466,9 +533,16 @@ bool _usesLegacyGeminiPredictApi(String modelName) { class _MultimodalInstance { final Map instance; - final _MultimodalOutput expectedOutput; + final List<_MultimodalExpectedOutput> expectedOutputs; + + _MultimodalInstance({required this.instance, required this.expectedOutputs}); +} + +class _MultimodalExpectedOutput { + final _MultimodalOutput output; + final Map metadata; - _MultimodalInstance({required this.instance, required this.expectedOutput}); + _MultimodalExpectedOutput({required this.output, required this.metadata}); } enum _MultimodalOutput { text, image, video } diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index 50694c74..3d8120ff 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -220,9 +220,80 @@ void main() { expect(requestBody['parameters'], {'dimension': 256}); expect(response.result.embeddings, hasLength(1)); expect(response.result.embeddings.first.embedding, [0.7, 0.8, 0.9]); + expect(response.result.embeddings.first.metadata, { + 'documentIndex': 0, + 'modality': 'text', + 'partIndices': [0], + }); }, ); + test('flattens mixed multimodal outputs with source metadata', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + final req = EmbedRequest( + input: [ + DocumentData( + content: [ + TextPart(text: 'hello'), + MediaPart(media: Media(url: 'data:image/png;base64,AA==')), + MediaPart(media: Media(url: 'data:video/mp4;base64,AA==')), + ], + ), + DocumentData(content: [TextPart(text: 'world')]), + ], + ); + + final response = await embedder.run(req); + + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances, hasLength(2)); + expect(instances.first, { + 'text': 'hello', + 'image': {'bytesBase64Encoded': 'AA==', 'mimeType': 'image/png'}, + 'video': {'bytesBase64Encoded': 'AA==', 'mimeType': 'video/mp4'}, + }); + expect(instances[1], {'text': 'world'}); + + final embeddings = response.result.embeddings; + expect(embeddings, hasLength(4)); + expect(embeddings[0].embedding, [0.7, 0.8, 0.9]); + expect(embeddings[0].metadata, { + 'documentIndex': 0, + 'modality': 'text', + 'partIndices': [0], + }); + expect(embeddings[1].embedding, [1.7, 1.8, 1.9]); + expect(embeddings[1].metadata, { + 'documentIndex': 0, + 'modality': 'image', + 'partIndex': 1, + }); + expect(embeddings[2].embedding, [2.7, 2.8, 2.9]); + expect(embeddings[2].metadata, { + 'documentIndex': 0, + 'modality': 'video', + 'partIndex': 2, + 'segmentIndex': 0, + 'startOffsetSec': 0, + 'endOffsetSec': 16, + }); + expect(embeddings[3].embedding, [1.7, 1.8, 1.9]); + expect(embeddings[3].metadata, { + 'documentIndex': 1, + 'modality': 'text', + 'partIndices': [0], + }); + }); + test( 'throws a descriptive error when Vertex returns no predictions', () async { diff --git a/packages/genkit_vertexai/test/test_http_client.dart b/packages/genkit_vertexai/test/test_http_client.dart index ef4de098..38e7aa4d 100644 --- a/packages/genkit_vertexai/test/test_http_client.dart +++ b/packages/genkit_vertexai/test/test_http_client.dart @@ -100,14 +100,27 @@ class MockHttpClient extends http.BaseClient { final instances = body['instances'] as List; final predictions = returnEmptyPredictions ? const [] - : List.generate( - instances.length, - (index) => returnMissingMultimodalEmbedding - ? {} - : { - 'textEmbedding': [index + 0.7, index + 0.8, index + 0.9], + : List.generate(instances.length, (index) { + if (returnMissingMultimodalEmbedding) { + return {}; + } + + final instance = instances[index] as Map; + return { + if (instance.containsKey('text')) + 'textEmbedding': [index + 0.7, index + 0.8, index + 0.9], + if (instance.containsKey('image')) + 'imageEmbedding': [index + 1.7, index + 1.8, index + 1.9], + if (instance.containsKey('video')) + 'videoEmbeddings': [ + { + 'embedding': [index + 2.7, index + 2.8, index + 2.9], + 'startOffsetSec': 0, + 'endOffsetSec': 16, }, - ); + ], + }; + }); return http.StreamedResponse( Stream.value(utf8.encode(jsonEncode({'predictions': predictions}))), 200, From 13c096fb20f442c810afde6c8d7206ac6cafadea Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 13:30:35 +0100 Subject: [PATCH 10/16] feat(genkit_google_genai): export additional generative language types for embedding requests --- packages/genkit_google_genai/lib/common.dart | 7 +++++++ packages/genkit_vertexai/lib/src/embedders.dart | 15 ++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/genkit_google_genai/lib/common.dart b/packages/genkit_google_genai/lib/common.dart index 4ca02b8b..64a73f57 100644 --- a/packages/genkit_google_genai/lib/common.dart +++ b/packages/genkit_google_genai/lib/common.dart @@ -17,4 +17,11 @@ library; export 'src/api_client.dart'; export 'src/common_plugin.dart'; +export 'src/generated/generativelanguage.dart' + show + BatchEmbedContentsRequest, + Content, + ContentEmbedding, + EmbedContentRequest, + Part; export 'src/model.dart'; diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 3acd4843..2acee365 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -16,9 +16,6 @@ import 'dart:convert'; import 'package:genkit/plugin.dart'; import 'package:genkit_google_genai/common.dart' as google; -// ignore: implementation_imports -import 'package:genkit_google_genai/src/generated/generativelanguage.dart' - as google_types; List> listVertexEmbedders({ required String pluginName, @@ -113,13 +110,13 @@ String _documentText(DocumentData doc) { return doc.content.where((p) => p.isText).map((p) => p.text).join('\n'); } -google_types.EmbedContentRequest _embedContentRequest( +google.EmbedContentRequest _embedContentRequest( DocumentData doc, google.TextEmbedderOptions? options, ) { final text = _documentText(doc); - return google_types.EmbedContentRequest( - content: google_types.Content(parts: [google_types.Part(text: text)]), + return google.EmbedContentRequest( + content: google.Content(parts: [google.Part(text: text)]), outputDimensionality: options?.outputDimensionality, taskType: options?.taskType, title: options?.title, @@ -144,7 +141,7 @@ Future> _runGeminiEmbeddingRequests({ } final res = await service.batchEmbedContents( - google_types.BatchEmbedContentsRequest( + google.BatchEmbedContentsRequest( requests: docs.map((doc) => _embedContentRequest(doc, options)).toList(), ), model: 'models/$embedderName', @@ -171,8 +168,8 @@ Future _runEmbedContentRequest({ return Embedding(embedding: res.embedding?.values ?? []); } -List _requireBatchEmbeddings( - List? embeddings, { +List _requireBatchEmbeddings( + List? embeddings, { required int expectedCount, }) { if (embeddings == null || embeddings.isEmpty) { From 9e3bb8f0486880414afdb3566989ebd5ee1f13b8 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 13:46:23 +0100 Subject: [PATCH 11/16] refactor(genkit_vertexai): clarify embedder request shape routing --- packages/genkit_vertexai/lib/src/embedders.dart | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 2acee365..82224658 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -503,13 +503,12 @@ String _baseModelName(String modelName) { _VertexEmbedderRequestShape _requestShapeFor(String modelName) { final baseModelName = _baseModelName(modelName); - // Check the broad model families in order. + final exactShape = _requestShapeByExactModel[baseModelName]; + if (exactShape != null) return exactShape; + if (_isMultimodalEmbeddingFamily(baseModelName)) { return _VertexEmbedderRequestShape.multimodalPredict; } - if (_usesLegacyGeminiPredictApi(baseModelName)) { - return _VertexEmbedderRequestShape.textPredict; - } if (_isGeminiEmbeddingFamily(baseModelName)) { return _VertexEmbedderRequestShape.geminiEmbedding; } @@ -524,9 +523,9 @@ bool _isGeminiEmbeddingFamily(String modelName) { return modelName.startsWith('gemini-embedding-'); } -bool _usesLegacyGeminiPredictApi(String modelName) { - return modelName == 'gemini-embedding-001'; -} +const _requestShapeByExactModel = { + 'gemini-embedding-001': _VertexEmbedderRequestShape.textPredict, +}; class _MultimodalInstance { final Map instance; From fb489ff03a03e931ef3ed259803f2c56770985b2 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 13:55:56 +0100 Subject: [PATCH 12/16] fix(genkit_vertexai): document multimodal dimension parameter --- packages/genkit_vertexai/lib/src/embedders.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 82224658..a3e22471 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -228,6 +228,8 @@ Future> _runMultimodalPredictRequests({ ]; final parameters = {}; if (options?.outputDimensionality != null) { + // Multimodal predict expects `parameters.dimension`, not + // `outputDimensionality`. parameters['dimension'] = options!.outputDimensionality; } From 308cc8874d1b46a0399d9f12907f212c1a0bc035 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 13:59:08 +0100 Subject: [PATCH 13/16] fix(genkit_vertexai): handle invalid prediction payloads in text embeddings --- .../genkit_vertexai/lib/src/embedders.dart | 13 +++++++-- .../genkit_vertexai/test/embedders_test.dart | 28 +++++++++++++++++++ .../test/test_http_client.dart | 14 ++++++---- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index a3e22471..8e9b8c8c 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -287,8 +287,17 @@ Future> _runTextPredictRequests({ } Embedding _textPredictionEmbedding(Map prediction) { - final embeddingData = prediction['embeddings'] as Map; - final values = embeddingData['values'] as List; + final embeddingData = prediction['embeddings']; + final values = embeddingData is Map + ? embeddingData['values'] + : null; + if (values is! List) { + throw GenkitException( + 'Vertex AI returned an invalid prediction payload.', + status: StatusCodes.INTERNAL, + ); + } + return Embedding( embedding: values.map((value) => (value as num).toDouble()).toList(), ); diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index 3d8120ff..9b030d0f 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -184,6 +184,34 @@ void main() { expect(requestBody['parameters'], {'outputDimensionality': 256}); }); + test('throws when a text prediction omits embedding values', () async { + final mockClient = MockHttpClient(returnInvalidTextPrediction: true); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'text-embedding-005'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData(content: [TextPart(text: 'hello')]), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Vertex AI returned an invalid prediction payload.'), + ), + ), + ); + }); + test( 'uses multimodal predict schema for text-only multimodal inputs', () async { diff --git a/packages/genkit_vertexai/test/test_http_client.dart b/packages/genkit_vertexai/test/test_http_client.dart index 38e7aa4d..ee040a64 100644 --- a/packages/genkit_vertexai/test/test_http_client.dart +++ b/packages/genkit_vertexai/test/test_http_client.dart @@ -19,10 +19,12 @@ import 'package:http/http.dart' as http; class MockHttpClient extends http.BaseClient { MockHttpClient({ this.returnEmptyPredictions = false, + this.returnInvalidTextPrediction = false, this.returnMissingMultimodalEmbedding = false, }); final bool returnEmptyPredictions; + final bool returnInvalidTextPrediction; final bool returnMissingMultimodalEmbedding; final List requestUrls = []; final List requestBodies = []; @@ -134,11 +136,13 @@ class MockHttpClient extends http.BaseClient { ? const [] : List.generate( instances.length, - (index) => { - 'embeddings': { - 'values': [index + 0.4, index + 0.5, index + 0.6], - }, - }, + (index) => returnInvalidTextPrediction + ? {'embeddings': {}} + : { + 'embeddings': { + 'values': [index + 0.4, index + 0.5, index + 0.6], + }, + }, ); return http.StreamedResponse( Stream.value(utf8.encode(jsonEncode({'predictions': predictions}))), From 0488245d518ea7f42199d42d7e3f20de8d8a7790 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 14:02:40 +0100 Subject: [PATCH 14/16] fix(genkit_vertexai): safely parse multimodal data URIs --- .../genkit_vertexai/lib/src/embedders.dart | 4 +- .../genkit_vertexai/test/embedders_test.dart | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/genkit_vertexai/lib/src/embedders.dart b/packages/genkit_vertexai/lib/src/embedders.dart index 8e9b8c8c..70b71e2e 100644 --- a/packages/genkit_vertexai/lib/src/embedders.dart +++ b/packages/genkit_vertexai/lib/src/embedders.dart @@ -376,7 +376,7 @@ MapEntry> _toMultimodalMediaField(Media media) { // Convert the media input into the format Vertex expects. if (media.url.startsWith('data:')) { - final data = Uri.parse(media.url).data; + final data = Uri.tryParse(media.url)?.data; if (data == null) { throw GenkitException( 'Vertex multimodalembedding media inputs require a valid data URI.', @@ -409,7 +409,7 @@ String? _mediaMimeType(Media media) { } if (media.url.startsWith('data:')) { - return Uri.parse(media.url).data?.mimeType; + return Uri.tryParse(media.url)?.data?.mimeType; } return null; diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index 9b030d0f..e9ce0196 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -322,6 +322,79 @@ void main() { }); }); + test('throws when a multimodal data URI is malformed', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart( + media: Media( + url: 'data:image/png;base64,%', + contentType: 'image/png', + ), + ), + ], + ), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Vertex multimodalembedding media inputs require a valid data URI.', + ), + ), + ), + ); + }); + + test('throws when a multimodal data URI MIME cannot be parsed', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart(media: Media(url: 'data:image/png;base64,%')), + ], + ), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Vertex multimodalembedding media inputs require a MIME type.', + ), + ), + ), + ); + }); + test( 'throws a descriptive error when Vertex returns no predictions', () async { From 3d061e600cce957ae2248118ea7a60f4696e9dad Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 14:32:52 +0100 Subject: [PATCH 15/16] feat(genkit_vertexai): add tests for multimodal image input handling and error cases --- .../genkit_vertexai/test/embedders_test.dart | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/packages/genkit_vertexai/test/embedders_test.dart b/packages/genkit_vertexai/test/embedders_test.dart index e9ce0196..9f0d2c9f 100644 --- a/packages/genkit_vertexai/test/embedders_test.dart +++ b/packages/genkit_vertexai/test/embedders_test.dart @@ -322,6 +322,74 @@ void main() { }); }); + test('uses data URI image inputs in multimodal predict requests', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart(media: Media(url: 'data:image/png;base64,AA==')), + ], + ), + ], + ), + ); + + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances.single, { + 'image': {'bytesBase64Encoded': 'AA==', 'mimeType': 'image/png'}, + }); + }); + + test('uses gs image inputs in multimodal predict requests', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart( + media: Media( + url: 'gs://my-bucket/image.png', + contentType: 'image/png', + ), + ), + ], + ), + ], + ), + ); + + final requestBody = + jsonDecode(mockClient.lastBody!) as Map; + final instances = requestBody['instances'] as List; + expect(instances.single, { + 'image': { + 'gcsUri': 'gs://my-bucket/image.png', + 'mimeType': 'image/png', + }, + }); + }); + test('throws when a multimodal data URI is malformed', () async { final mockClient = MockHttpClient(); final plugin = VertexAiPluginImpl( @@ -395,6 +463,109 @@ void main() { ); }); + test('throws when a multimodal document has multiple images', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart(media: Media(url: 'data:image/png;base64,AA==')), + MediaPart(media: Media(url: 'data:image/jpeg;base64,AA==')), + ], + ), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Vertex multimodalembedding supports at most one image part per input document.', + ), + ), + ), + ); + }); + + test('throws when multimodal media has an unsupported MIME type', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart(media: Media(url: 'data:audio/wav;base64,AA==')), + ], + ), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Unsupported Vertex multimodalembedding media MIME type: audio/wav', + ), + ), + ), + ); + }); + + test('throws when multimodal media is missing a MIME type', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final embedder = _resolveEmbedder(plugin, 'multimodalembedding'); + + await expectLater( + () => embedder.run( + EmbedRequest( + input: [ + DocumentData( + content: [ + MediaPart(media: Media(url: 'gs://my-bucket/image.png')), + ], + ), + ], + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Vertex multimodalembedding media inputs require a MIME type.', + ), + ), + ), + ); + }); + test( 'throws a descriptive error when Vertex returns no predictions', () async { From 4256c027aad72f76fe8a216f1da8e4cb2c0144cb Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 12 May 2026 14:44:12 +0100 Subject: [PATCH 16/16] fix(generativelanguage): update documentation for soft tokens tensor frame shape format --- .../lib/src/generated/generativelanguage.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/genkit_google_genai/lib/src/generated/generativelanguage.dart b/packages/genkit_google_genai/lib/src/generated/generativelanguage.dart index 6ea92a04..b84ac2d2 100644 --- a/packages/genkit_google_genai/lib/src/generated/generativelanguage.dart +++ b/packages/genkit_google_genai/lib/src/generated/generativelanguage.dart @@ -1769,7 +1769,7 @@ extension type ContentEmbedding._(Map _data) { set values(List? value) => _data['values'] = value; - /// This field stores the soft tokens tensor frame shape (e.g. [1, 1, 256, 2048]). + /// This field stores the soft tokens tensor frame shape (e.g. `[1, 1, 256, 2048]`). List? get shape { final v = _data['shape']; if (v == null) return null;