diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_api.dart b/packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_api.dart index 3661acb3a49b..aca7b6583e96 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_api.dart +++ b/packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_api.dart @@ -226,6 +226,36 @@ final class Candidate { /// Message for finish reason. final String? finishMessage; + + /// The concatenation of the text parts of [content], if any. + /// + /// If this candidate was finished for a reason of [FinishReason.recitation] + /// or [FinishReason.safety], accessing this text will throw a + /// [GenerativeAIException]. + /// + /// If [content] contains any text parts, this value is the concatenation of + /// the text. + /// + /// If [content] does not contain any text parts, this value is `null`. + String? get text { + if (finishReason case FinishReason.recitation || FinishReason.safety) { + final String suffix; + if (finishMessage case final message? when message.isNotEmpty) { + suffix = ': $message'; + } else { + suffix = ''; + } + throw VertexAIException( + 'Candidate was blocked due to $finishReason$suffix'); + } + return switch (content.parts) { + // Special case for a single TextPart to avoid iterable chain. + [TextPart(:final text)] => text, + final parts when parts.any((p) => p is TextPart) => + parts.whereType().map((p) => p.text).join(), + _ => null, + }; + } } /// Safety rating for a piece of content. diff --git a/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml b/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml index 26e31dbecaff..64ebab3ad882 100644 --- a/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml +++ b/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml @@ -32,6 +32,6 @@ dev_dependencies: flutter_lints: ^4.0.0 flutter_test: sdk: flutter + matcher: ^0.12.16 mockito: ^5.0.0 plugin_platform_interface: ^2.1.3 - matcher: ^0.12.16 diff --git a/packages/firebase_vertexai/firebase_vertexai/test/utils/matchers.dart b/packages/firebase_vertexai/firebase_vertexai/test/utils/matchers.dart index 1f2d07367fea..7e9256aa6d03 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/utils/matchers.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/utils/matchers.dart @@ -32,7 +32,6 @@ Matcher matchesPart(Part part) => switch (part) { isA() .having((p) => p.name, 'name', name) .having((p) => p.response, 'args', response), - _ => throw StateError('Unhandled Part type.'), }; Matcher matchesContent(Content content) => isA() diff --git a/packages/firebase_vertexai/firebase_vertexai/test/vertex_response_parsing_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/vertex_response_parsing_test.dart index f39aba85fc4f..86ddad8f38e8 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/vertex_response_parsing_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/vertex_response_parsing_test.dart @@ -346,8 +346,8 @@ void main() { ), ], CitationMetadata([ - CitationSource(574, 705, Uri.https('example.com', ''), ''), - CitationSource(899, 1026, Uri.https('example.com', ''), ''), + CitationSource(574, 705, Uri.https('example.com'), ''), + CitationSource(899, 1026, Uri.https('example.com'), ''), ]), FinishReason.stop, null, @@ -480,8 +480,8 @@ void main() { ), ], CitationMetadata([ - CitationSource(574, 705, Uri.https('example.com', ''), ''), - CitationSource(899, 1026, Uri.https('example.com', ''), ''), + CitationSource(574, 705, Uri.https('example.com'), ''), + CitationSource(899, 1026, Uri.https('example.com'), ''), ]), FinishReason.stop, null, @@ -563,37 +563,37 @@ void main() { ); }); -// test('text getter joins content', () async { -// const response = ''' -// { -// "candidates": [ -// { -// "content": { -// "parts": [ -// { -// "text": "Initial text" -// }, -// { -// "functionCall": {"name": "someFunction", "args": {}} -// }, -// { -// "text": " And more text" -// } -// ], -// "role": "model" -// }, -// "finishReason": "STOP", -// "index": 0 -// } -// ] -// } -// '''; -// final decoded = jsonDecode(response) as Object; -// final generateContentResponse = parseGenerateContentResponse(decoded); -// expect(generateContentResponse.text, 'Initial text And more text'); -// expect(generateContentResponse.candidates.single.text, -// 'Initial text And more text'); -// }); + test('text getter joins content', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Initial text" + }, + { + "functionCall": {"name": "someFunction", "args": {}} + }, + { + "text": " And more text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = parseGenerateContentResponse(decoded); + expect(generateContentResponse.text, 'Initial text And more text'); + expect(generateContentResponse.candidates.single.text, + 'Initial text And more text'); + }); }); group('parses and throws error responses', () {