diff --git a/packages/genkit_google_genai/lib/src/api_client.dart b/packages/genkit_google_genai/lib/src/api_client.dart index e667b98b..5c25df7a 100644 --- a/packages/genkit_google_genai/lib/src/api_client.dart +++ b/packages/genkit_google_genai/lib/src/api_client.dart @@ -79,6 +79,13 @@ class GenerativeLanguageBaseClient { return await _call('POST', url, request); } + Future> interactions( + Map request, + ) async { + final url = '${apiUrlPrefix}interactions'; + return await _call('POST', url, request); + } + Stream streamGenerateContent( GenerateContentRequest request, { required String model, diff --git a/packages/genkit_vertexai/lib/genkit_vertexai.dart b/packages/genkit_vertexai/lib/genkit_vertexai.dart index e9f02385..4833cd4c 100644 --- a/packages/genkit_vertexai/lib/genkit_vertexai.dart +++ b/packages/genkit_vertexai/lib/genkit_vertexai.dart @@ -16,6 +16,7 @@ import 'package:genkit/plugin.dart'; import 'package:genkit_google_genai/genkit_google_genai.dart'; import 'package:http/http.dart' as http; +import 'src/lyria.dart'; import 'src/vertex_api_client.dart'; export 'package:genkit_google_genai/genkit_google_genai.dart' @@ -31,6 +32,7 @@ export 'package:genkit_google_genai/genkit_google_genai.dart' TextEmbedderOptions, ThinkingConfig, VoiceConfig; +export 'src/lyria.dart' show LyriaOptions; const VertexAiPluginHandle vertexAI = VertexAiPluginHandle(); @@ -53,6 +55,10 @@ class VertexAiPluginHandle { return modelRef('vertexai/$name', customOptions: GeminiOptions.$schema); } + ModelRef lyria([String name = 'lyria-002']) { + return modelRef('vertexai/$name', customOptions: LyriaOptions.$schema); + } + EmbedderRef textEmbedding(String name) { return embedderRef( 'vertexai/$name', diff --git a/packages/genkit_vertexai/lib/src/lyria.dart b/packages/genkit_vertexai/lib/src/lyria.dart new file mode 100644 index 00000000..876cd638 --- /dev/null +++ b/packages/genkit_vertexai/lib/src/lyria.dart @@ -0,0 +1,407 @@ +// 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:schemantic/schemantic.dart'; + +typedef LyriaApiClientProvider = + Future Function(); +typedef LyriaExceptionHandler = + GenkitException Function(Object error, StackTrace stackTrace); + +final lyriaModelInfo = ModelInfo(supports: {'media': true}, label: 'Lyria'); + +base class LyriaOptions { + factory LyriaOptions.fromJson(Map json) => + $schema.parse(json); + + LyriaOptions._(this._json); + + LyriaOptions({String? negativePrompt, int? seed, int? sampleCount}) { + _json = { + 'negativePrompt': ?negativePrompt, + 'seed': ?seed, + 'sampleCount': ?sampleCount, + }; + } + + late final Map _json; + + static const SchemanticType $schema = + _LyriaOptionsTypeFactory(); + + String? get negativePrompt => _json['negativePrompt'] as String?; + int? get seed => _json['seed'] as int?; + int? get sampleCount => _json['sampleCount'] as int?; + + Map toJson() => _json; +} + +base class _LyriaOptionsTypeFactory extends SchemanticType { + const _LyriaOptionsTypeFactory(); + + @override + LyriaOptions parse(Object? json) { + return LyriaOptions._((json as Map).cast()); + } + + @override + JsonSchemaMetadata get schemaMetadata => JsonSchemaMetadata( + name: 'LyriaOptions', + definition: { + 'type': 'object', + 'properties': { + 'negativePrompt': { + 'type': 'string', + 'description': 'Description of audio elements to exclude.', + }, + 'seed': { + 'type': 'integer', + 'description': + 'Optional deterministic seed. Cannot be used with sampleCount.', + }, + 'sampleCount': { + 'type': 'integer', + 'minimum': 1, + 'description': + 'Optional number of audio samples. Cannot be used with seed.', + }, + }, + }, + ); +} + +Model createLyriaModel({ + required String pluginName, + required String modelName, + required LyriaApiClientProvider getApiClient, + required LyriaApiClientProvider getInteractionsApiClient, + required LyriaExceptionHandler handleException, + required bool closeClient, +}) { + return Model( + name: '$pluginName/$modelName', + customOptions: LyriaOptions.$schema, + metadata: {'model': lyriaModelInfo.toJson()}, + fn: (req, ctx) async { + if (req == null) { + throw GenkitException( + 'Lyria request cannot be null.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + final modelRequest = req; + final options = modelRequest.config == null + ? LyriaOptions() + : LyriaOptions.$schema.parse(modelRequest.config!); + final isLyria3 = modelName.startsWith('lyria-3-'); + final service = await (isLyria3 + ? getInteractionsApiClient() + : getApiClient()); + + try { + if (isLyria3) { + final response = await service.interactions( + toLyriaInteractionsRequest(modelRequest, modelName), + ); + return fromLyriaInteractionsResponse(response); + } else { + final response = await service.predict( + toLyriaPredictRequest(modelRequest, options), + model: 'models/$modelName', + ); + return fromLyriaPredictResponse(response); + } + } catch (e, stack) { + throw handleException(e, stack); + } finally { + if (closeClient) { + service.client.close(); + } + } + }, + ); +} + +Map toLyriaPredictRequest( + ModelRequest request, + LyriaOptions options, +) { + if (options.seed != null && options.sampleCount != null) { + throw GenkitException( + 'Lyria seed and sampleCount cannot be used together.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + final prompt = _promptFromMessages(request.messages); + if (prompt.isEmpty) { + throw GenkitException( + 'Lyria requires a text prompt.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + return { + 'instances': [ + { + 'prompt': prompt, + 'negative_prompt': ?options.negativePrompt, + 'seed': ?options.seed, + }, + ], + 'parameters': {'sample_count': ?options.sampleCount}, + }; +} + +Map toLyriaInteractionsRequest( + ModelRequest request, + String modelName, +) { + final input = _inputFromMessages(request.messages); + if (input.isEmpty) { + throw GenkitException( + 'Lyria requires a text prompt.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + return {'model': modelName, 'input': input}; +} + +ModelResponse fromLyriaPredictResponse(Map response) { + final predictions = response['predictions'] as List?; + if (predictions == null || predictions.isEmpty) { + throw GenkitException('Lyria returned no predictions.'); + } + + final parts = predictions.map((prediction) { + final predictionMap = prediction as Map; + final audioContent = _extractAudioContent(predictionMap); + if (audioContent == null || audioContent.isEmpty) { + throw GenkitException( + 'Lyria prediction did not include audio content. ' + 'Prediction keys: ${predictionMap.keys.join(', ')}. ' + 'Prediction: ${jsonEncode(predictionMap)}', + ); + } + final mimeType = predictionMap['mimeType'] as String? ?? 'audio/wav'; + return MediaPart( + media: Media( + contentType: mimeType, + url: 'data:$mimeType;base64,$audioContent', + ), + ); + }).toList(); + + return ModelResponse( + finishReason: FinishReason.stop, + message: Message(role: Role.model, content: parts), + raw: response, + ); +} + +ModelResponse fromLyriaInteractionsResponse(Map response) { + final outputs = response['outputs'] as List?; + if (outputs == null || outputs.isEmpty) { + throw _filteredLyriaResponseException(response); + } + + final outputMaps = outputs + .map((output) => (output as Map).cast()) + .toList(); + final audioParts = outputMaps + .map(_audioPartFromLyriaOutput) + .nonNulls + .toList(); + final textOutputs = _lyriaTextOutputs(outputMaps); + final lyricsText = _lyriaLyricsText(textOutputs); + + if (audioParts.isEmpty) { + final filteredReason = _filteredReason(response); + if (filteredReason != null) { + throw _filteredLyriaResponseException(response); + } + throw GenkitException( + 'Lyria returned no supported outputs. ' + 'Output types: ${outputs.map((o) => (o as Map)['type']).join(', ')}.', + details: jsonEncode(response), + ); + } + + final content = [ + if (lyricsText != null) TextPart(text: lyricsText), + ...audioParts, + ]; + final additionalTextOutputs = _additionalTextOutputs(textOutputs); + + return ModelResponse( + finishReason: FinishReason.stop, + message: Message(role: Role.model, content: content), + custom: additionalTextOutputs.isEmpty + ? null + : {'additionalTextOutputs': additionalTextOutputs}, + raw: response, + ); +} + +Part? _audioPartFromLyriaOutput(Map output) { + final type = output['type'] as String?; + if (type == 'audio') { + final audioContent = _extractAudioContent(output); + if (audioContent == null || audioContent.isEmpty) return null; + final mimeType = + output['mime_type'] as String? ?? + output['mimeType'] as String? ?? + 'audio/mpeg'; + return MediaPart( + media: Media( + contentType: mimeType, + url: 'data:$mimeType;base64,$audioContent', + ), + ); + } + return null; +} + +List> _lyriaTextOutputs( + List> outputs, +) { + return outputs.where((output) => output['type'] == 'text').where((output) { + final text = output['text'] as String?; + return text != null && text.isNotEmpty; + }).toList(); +} + +String? _lyriaLyricsText(List> textOutputs) { + if (textOutputs.isEmpty) return null; + return textOutputs.first['text'] as String?; +} + +List> _additionalTextOutputs( + List> textOutputs, +) { + return textOutputs.skip(1).toList(); +} + +GenkitException _filteredLyriaResponseException(Map response) { + final reason = _filteredReason(response); + final reasonText = reason == null ? '' : ' Reason: $reason.'; + return GenkitException( + 'Lyria request was filtered and returned no outputs.$reasonText', + status: StatusCodes.FAILED_PRECONDITION, + details: jsonEncode(response), + ); +} + +String? _filteredReason(Map response) { + return _stringValue(response['blockReason']) ?? + _stringValue(response['blockedReason']) ?? + _stringValue(response['finishReason']) ?? + _stringValue(response['finishMessage']) ?? + _stringValue(response['statusMessage']) ?? + _stringValue(response['error']) ?? + _stringValue(response['promptFeedback']) ?? + _stringValue(response['safetyFeedback']) ?? + _stringValue(response['safetyRatings']); +} + +String? _extractAudioContent(Map prediction) { + return _stringValue(prediction['audioContent']) ?? + _stringValue(prediction['bytesBase64Encoded']) ?? + _stringValue(prediction['data']) ?? + _stringValue(prediction['audioBytes']) ?? + _stringValue(prediction['audio']); +} + +String? _stringValue(Object? value) { + if (value is String) return value; + if (value is Map) { + final map = value.cast(); + return _stringValue(map['message']) ?? + _stringValue(map['reason']) ?? + _stringValue(map['blockReason']) ?? + _stringValue(map['blockedReason']) ?? + _stringValue(map['finishReason']) ?? + _stringValue(map['finishMessage']) ?? + _stringValue(map['bytesBase64Encoded']) ?? + _stringValue(map['data']) ?? + _stringValue(map['audioContent']); + } + return null; +} + +List> _inputFromMessages(List messages) { + final input = >[]; + final userMessages = messages.where((message) => message.role == Role.user); + final inputMessages = userMessages.isEmpty ? messages : userMessages; + + for (final message in inputMessages) { + for (final part in message.content) { + if (part.isText && part.text?.isNotEmpty == true) { + input.add({'type': 'text', 'text': part.text}); + } else if (part.isMedia) { + input.add(_mediaInput(part.media!)); + } + } + } + + return input; +} + +Map _mediaInput(Media media) { + final data = media.url.startsWith('data:') ? Uri.parse(media.url).data : null; + final mimeType = media.contentType ?? data?.mimeType; + + if (mimeType == null || mimeType.isEmpty) { + throw GenkitException( + 'Lyria image inputs require an image content type.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + if (!mimeType.startsWith('image/')) { + throw GenkitException( + 'Lyria supports only image media inputs. Received $mimeType.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } + + if (media.url.startsWith('data:')) { + if (data != null) { + return { + 'type': 'image', + 'mime_type': mimeType, + 'data': base64Encode(data.contentAsBytes()), + }; + } + } + return {'type': 'image', 'mime_type': mimeType, 'uri': media.url}; +} + +String _promptFromMessages(List messages) { + final userMessages = messages.where((message) => message.role == Role.user); + final promptMessages = userMessages.isEmpty ? messages : userMessages; + return promptMessages + .map( + (message) => message.content + .where((part) => part.isText) + .map((part) => part.text) + .join('\n'), + ) + .where((text) => text.isNotEmpty) + .join('\n'); +} diff --git a/packages/genkit_vertexai/lib/src/vertex_api_client.dart b/packages/genkit_vertexai/lib/src/vertex_api_client.dart index dd0a5cf7..064ea318 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 'lyria.dart'; @visibleForTesting class VertexAiPluginImpl extends CommonGoogleGenPlugin { @@ -77,6 +78,65 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { ); } + Future getLyriaApiClient() async { + final validFormat = RegExp(r'^[a-z0-9-]+$'); + final resolvedProjectId = _getResolvedProjectId; + final resolvedLocation = location ?? 'global'; + + if (!validFormat.hasMatch(resolvedLocation) || + !validFormat.hasMatch(resolvedProjectId)) { + throw ArgumentError('Invalid projectId or location format.'); + } + final safeLocation = Uri.encodeComponent(resolvedLocation); + final safeProjectId = Uri.encodeComponent(resolvedProjectId); + + final tokenProvider = createAdcAccessTokenProvider(baseClient: authClient); + + final baseUrl = safeLocation == 'global' + ? 'https://aiplatform.googleapis.com/' + : 'https://$safeLocation-aiplatform.googleapis.com/'; + final apiUrlPrefix = + 'v1/projects/$safeProjectId/locations/$safeLocation/publishers/google/'; + + final headers = {'X-Goog-Api-Client': googleApiClientHeaderValue()}; + final customClient = CustomClient( + defaultHeaders: headers, + inner: authClient, + ); + final client = VertexAuthClient(tokenProvider, inner: customClient); + + return GenerativeLanguageBaseClient( + baseUrl: baseUrl, + client: client, + apiUrlPrefix: apiUrlPrefix, + ); + } + + Future getLyriaInteractionsApiClient() async { + final validFormat = RegExp(r'^[a-z0-9-]+$'); + final resolvedProjectId = _getResolvedProjectId; + + if (!validFormat.hasMatch(resolvedProjectId)) { + throw ArgumentError('Invalid projectId format.'); + } + final safeProjectId = Uri.encodeComponent(resolvedProjectId); + + final tokenProvider = createAdcAccessTokenProvider(baseClient: authClient); + + final headers = {'X-Goog-Api-Client': googleApiClientHeaderValue()}; + final customClient = CustomClient( + defaultHeaders: headers, + inner: authClient, + ); + final client = VertexAuthClient(tokenProvider, inner: customClient); + + return GenerativeLanguageBaseClient( + baseUrl: 'https://aiplatform.googleapis.com/', + client: client, + apiUrlPrefix: 'v1beta1/projects/$safeProjectId/locations/global/', + ); + } + @override Future>> list() async { @@ -91,18 +151,22 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { .where((m) { final modelMap = m as Map; final name = modelMap['name'] as String?; - return name != null && name.contains('gemini-'); + return name != null && + (name.contains('gemini-') || name.contains('lyria-')); }) .map((m) { final modelMap = m as Map; final modelName = (modelMap['name'] as String).split('/').last; final isTts = modelName.contains('-tts'); + final isLyria = modelName.startsWith('lyria-'); return modelMetadata( '$name/$modelName', - customOptions: isTts + customOptions: isLyria + ? LyriaOptions.$schema + : isTts ? GeminiTtsOptions.$schema : GeminiOptions.$schema, - modelInfo: commonModelInfo, + modelInfo: isLyria ? lyriaModelInfo : commonModelInfo, ); }) .toList(); @@ -190,4 +254,23 @@ class VertexAiPluginImpl extends CommonGoogleGenPlugin { }, ); } + + Model createLyria(String modelName) { + return createLyriaModel( + pluginName: name, + modelName: modelName, + getApiClient: getLyriaApiClient, + getInteractionsApiClient: getLyriaInteractionsApiClient, + handleException: handleException, + closeClient: authClient == null, + ); + } + + @override + Action? resolve(String actionType, String name) { + if (actionType == 'model' && name.startsWith('lyria-')) { + return createLyria(name); + } + return super.resolve(actionType, name); + } } diff --git a/packages/genkit_vertexai/test/lyria_test.dart b/packages/genkit_vertexai/test/lyria_test.dart new file mode 100644 index 00000000..66ef2c30 --- /dev/null +++ b/packages/genkit_vertexai/test/lyria_test.dart @@ -0,0 +1,279 @@ +// 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/lyria.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; + Map? lastBody; + + @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'}, + ); + } + if (request is http.Request && request.body.isNotEmpty) { + lastBody = jsonDecode(request.body) as Map; + } + if (request.url.path.endsWith('/interactions')) { + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"status": "completed", "outputs": [{"type": "text", "text": "lyrics"}, {"type": "text", "text": "description"}, {"type": "audio", "mime_type": "audio/mpeg", "data": "SUQz"}]}', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.StreamedResponse( + Stream.value( + utf8.encode( + '{"predictions": [{"audioContent": "UklGRg==", "mimeType": "audio/wav"}]}', + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } +} + +void main() { + group('Vertex AI Lyria models', () { + test('rejects null model requests', () async { + final plugin = VertexAiPluginImpl(projectId: 'my-project'); + final model = plugin.resolve('model', 'lyria-002') as Action; + + await expectLater( + model.run(null), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + 'Lyria request cannot be null.', + ) + .having((e) => e.status, 'status', StatusCodes.INVALID_ARGUMENT), + ), + ); + }); + + test('uses predict endpoint and returns audio media', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final model = plugin.resolve('model', 'lyria-002') as Action; + final req = ModelRequest( + messages: [ + Message( + role: Role.user, + content: [TextPart(text: 'calm acoustic folk song')], + ), + ], + config: LyriaOptions(negativePrompt: 'drums', seed: 98765).toJson(), + ); + + final runResult = await model.run(req); + final response = (runResult as dynamic).result as ModelResponse; + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/lyria-002:predict', + ); + expect(mockClient.lastBody, { + 'instances': [ + { + 'prompt': 'calm acoustic folk song', + 'negative_prompt': 'drums', + 'seed': 98765, + }, + ], + 'parameters': {}, + }); + final message = response.message!; + final part = message.content.single; + expect(part.isMedia, true); + expect(part.media!.contentType, 'audio/wav'); + expect(part.media!.url, 'data:audio/wav;base64,UklGRg=='); + }); + + test('uses interactions endpoint for Lyria 3 models', () async { + final mockClient = MockHttpClient(); + final plugin = VertexAiPluginImpl( + projectId: 'my-project', + location: 'us-central1', + authClient: mockClient, + ); + + final model = plugin.resolve('model', 'lyria-3-clip-preview') as Action; + final req = ModelRequest( + messages: [ + Message( + role: Role.user, + content: [TextPart(text: 'short electronic theme')], + ), + ], + ); + + final runResult = await model.run(req); + final response = (runResult as dynamic).result as ModelResponse; + + expect(mockClient.lastUrl, isNotNull); + expect( + mockClient.lastUrl.toString(), + 'https://aiplatform.googleapis.com/v1beta1/projects/my-project/locations/global/interactions', + ); + expect(mockClient.lastBody, { + 'model': 'lyria-3-clip-preview', + 'input': [ + {'type': 'text', 'text': 'short electronic theme'}, + ], + }); + final message = response.message!; + expect(response.text, 'lyrics'); + expect(response.custom, { + 'additionalTextOutputs': [ + {'type': 'text', 'text': 'description'}, + ], + }); + expect(message.content.first.text, 'lyrics'); + final part = message.content.last; + expect(part.isMedia, true); + expect(part.media!.contentType, 'audio/mpeg'); + expect(part.media!.url, 'data:audio/mpeg;base64,SUQz'); + }); + + test('sends image media inputs to Lyria 3 interactions', () { + final req = ModelRequest( + messages: [ + Message( + role: Role.user, + content: [ + TextPart(text: 'short electronic theme'), + MediaPart(media: Media(url: 'data:image/png;base64,AQID')), + ], + ), + ], + ); + + expect(toLyriaInteractionsRequest(req, 'lyria-3-clip-preview'), { + 'model': 'lyria-3-clip-preview', + 'input': [ + {'type': 'text', 'text': 'short electronic theme'}, + {'type': 'image', 'mime_type': 'image/png', 'data': 'AQID'}, + ], + }); + }); + + test('rejects audio media inputs for Lyria 3 interactions', () { + final req = ModelRequest( + messages: [ + Message( + role: Role.user, + content: [ + TextPart(text: 'short electronic theme'), + MediaPart(media: Media(url: 'data:audio/mpeg;base64,SUQz')), + ], + ), + ], + ); + + expect( + () => toLyriaInteractionsRequest(req, 'lyria-3-clip-preview'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Lyria supports only image media inputs. Received audio/mpeg.', + ), + ), + ); + }); + + test('reports filtered Lyria 3 interactions clearly', () { + final response = { + 'status': 'completed', + 'outputs': >[], + 'promptFeedback': {'blockReason': 'PROHIBITED_CONTENT'}, + }; + + expect( + () => fromLyriaInteractionsResponse(response), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + 'Lyria request was filtered and returned no outputs. ' + 'Reason: PROHIBITED_CONTENT.', + ) + .having( + (e) => e.status, + 'status', + StatusCodes.FAILED_PRECONDITION, + ) + .having((e) => e.details, 'details', jsonEncode(response)), + ), + ); + }); + + test('accepts nested bytesBase64Encoded audio content', () { + final response = fromLyriaPredictResponse({ + 'predictions': [ + { + 'audioContent': {'bytesBase64Encoded': 'UklGRg=='}, + 'mimeType': 'audio/wav', + }, + ], + }); + + final part = response.message!.content.single; + expect(part.isMedia, true); + expect(part.media!.url, 'data:audio/wav;base64,UklGRg=='); + }); + + test('accepts top-level data audio content', () { + final response = fromLyriaPredictResponse({ + 'predictions': [ + {'data': 'UklGRg==', 'mimeType': 'audio/wav'}, + ], + }); + + final part = response.message!.content.single; + expect(part.isMedia, true); + expect(part.media!.url, 'data:audio/wav;base64,UklGRg=='); + }); + }); +}