diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index 653ef2097013..99940b367a31 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -573,7 +573,7 @@ enum BlockReason { 'BLOCK_REASON_UNSPECIFIED' => BlockReason.unknown, 'SAFETY' => BlockReason.safety, 'OTHER' => BlockReason.other, - _ => throw FormatException('Unhandled BlockReason format', jsonObject), + _ => BlockReason.unknown, }; } @@ -618,7 +618,7 @@ enum HarmCategory { 'HARM_CATEGORY_HATE_SPEECH' => HarmCategory.hateSpeech, 'HARM_CATEGORY_SEXUALLY_EXPLICIT' => HarmCategory.sexuallyExplicit, 'HARM_CATEGORY_DANGEROUS_CONTENT' => HarmCategory.dangerousContent, - _ => throw FormatException('Unhandled HarmCategory format', jsonObject), + _ => HarmCategory.unknown, }; } @@ -661,8 +661,7 @@ enum HarmProbability { 'LOW' => HarmProbability.low, 'MEDIUM' => HarmProbability.medium, 'HIGH' => HarmProbability.high, - _ => - throw FormatException('Unhandled HarmProbability format', jsonObject), + _ => HarmProbability.unknown, }; } @@ -704,7 +703,7 @@ enum HarmSeverity { 'HARM_SEVERITY_LOW' => HarmSeverity.low, 'HARM_SEVERITY_MEDIUM' => HarmSeverity.medium, 'HARM_SEVERITY_HIGH' => HarmSeverity.high, - _ => throw FormatException('Unhandled HarmSeverity format', jsonObject), + _ => HarmSeverity.unknown, }; } @@ -790,7 +789,7 @@ enum FinishReason { 'RECITATION' => FinishReason.recitation, 'OTHER' => FinishReason.other, 'MALFORMED_FUNCTION_CALL' => FinishReason.malformedFunctionCall, - _ => throw FormatException('Unhandled FinishReason format', jsonObject), + _ => FinishReason.other, }; } @@ -840,8 +839,7 @@ enum ContentModality { 'VIDEO' => ContentModality.video, 'AUDIO' => ContentModality.audio, 'DOCUMENT' => ContentModality.document, - _ => - throw FormatException('Unhandled ContentModality format', jsonObject), + _ => ContentModality.unspecified, }; } @@ -946,8 +944,7 @@ enum HarmBlockMethod { 'SEVERITY' => HarmBlockMethod.severity, 'PROBABILITY' => HarmBlockMethod.probability, 'HARM_BLOCK_METHOD_UNSPECIFIED' => HarmBlockMethod.unspecified, - _ => - throw FormatException('Unhandled HarmBlockMethod format', jsonObject), + _ => HarmBlockMethod.unspecified, }; } @@ -1292,7 +1289,7 @@ enum TaskType { 'SEMANTIC_SIMILARITY' => TaskType.semanticSimilarity, 'CLASSIFICATION' => TaskType.classification, 'CLUSTERING' => TaskType.clustering, - _ => throw FormatException('Unhandled TaskType format', jsonObject), + _ => TaskType.unspecified, }; } @@ -1818,7 +1815,7 @@ enum Outcome { 'OUTCOME_OK' => Outcome.ok, 'OUTCOME_FAILED' => Outcome.failed, 'OUTCOME_DEADLINE_EXCEEDED' => Outcome.deadlineExceeded, - _ => throw FormatException('Unhandled Outcome format', jsonObject), + _ => Outcome.unspecified, }; } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/content.dart b/packages/firebase_ai/firebase_ai/lib/src/content.dart index c9ed7813c4ee..8fa2b7b14ad1 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/content.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/content.dart @@ -103,12 +103,12 @@ Part parsePart(Object? jsonObject) { if (jsonObject.containsKey('functionCall')) { final functionCall = jsonObject['functionCall']; - if (functionCall is Map && - functionCall.containsKey('name') && - functionCall.containsKey('args')) { + if (functionCall is Map && functionCall.containsKey('name')) { return FunctionCall._( functionCall['name'] as String, - functionCall['args'] as Map, + functionCall.containsKey('args') + ? functionCall['args'] as Map + : {}, id: functionCall['id'] as String?, isThought: isThought, thoughtSignature: thoughtSignature, diff --git a/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart index 5aa1809d2023..e3b9aa45259b 100644 --- a/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart +++ b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart @@ -1115,6 +1115,150 @@ void main() { expect(urlContextMetadata.urlMetadata[0].urlRetrievalStatus, UrlRetrievalStatus.error); }); + + test('with unknown safety ratings', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Some text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "MEDIUM" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "FAKE_NEW_HARM_PROBABILITY" + }, + { + "category": "FAKE_NEW_HARM_CATEGORY", + "probability": "HIGH" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "MEDIUM" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "FAKE_NEW_HARM_PROBABILITY" + }, + { + "category": "FAKE_NEW_HARM_CATEGORY", + "probability": "HIGH" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + const TextPart('Some text'), + ]), + [ + SafetyRating( + HarmCategory.harassment, + HarmProbability.medium, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.unknown, + ), + SafetyRating( + HarmCategory.unknown, + HarmProbability.high, + ), + ], + null, + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.harassment, + HarmProbability.medium, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.unknown, + ), + SafetyRating( + HarmCategory.unknown, + HarmProbability.high, + ), + ]), + ), + ), + ); + }); + + test('with an empty function call', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "current_time" + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + const FunctionCall('current_time', {}), + ]), + null, + null, + FinishReason.stop, + null, + ), + ], + null, + ), + ), + ); + }); }); group('parses and throws error responses', () { diff --git a/tests/integration_test/e2e_test.dart b/tests/integration_test/e2e_test.dart index 7f5d657b1b56..2cc98dabf91c 100644 --- a/tests/integration_test/e2e_test.dart +++ b/tests/integration_test/e2e_test.dart @@ -27,6 +27,7 @@ import 'firebase_performance/firebase_performance_e2e_test.dart' import 'firebase_remote_config/firebase_remote_config_e2e_test.dart' as firebase_remote_config; import 'firebase_storage/firebase_storage_e2e_test.dart' as firebase_storage; +import 'firebase_ai/firebase_ai_e2e_test.dart' as firebase_ai; // Github Actions environment variable // ignore: do_not_use_environment @@ -62,6 +63,7 @@ void main() { firebase_performance.main(); firebase_remote_config.main(); firebase_storage.main(); + firebase_ai.main(); return; } @@ -98,5 +100,6 @@ void runAllTests() { firebase_performance.main(); firebase_remote_config.main(); firebase_storage.main(); + firebase_ai.main(); firebase_app_check.main(); } diff --git a/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart b/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart new file mode 100644 index 000000000000..11fd0d89d44a --- /dev/null +++ b/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart @@ -0,0 +1,105 @@ +// Copyright 2026 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:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:http/http.dart' as http; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/developer/api.dart'; +import 'package:firebase_ai/src/imagen/imagen_content.dart'; +import 'package:tests/firebase_options.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('firebase_ai e2e', () { + setUpAll(() async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + }); + + test('test against all json responses from vertexai-sdk-test-data', + () async { + final treeUrl = Uri.parse( + 'https://api.github.com/repos/FirebaseExtended/vertexai-sdk-test-data/git/trees/main?recursive=1', + ); + final treeResponse = await http.get(treeUrl); + if (treeResponse.statusCode != 200) { + fail('Failed to fetch tree: ${treeResponse.statusCode}'); + } + final treeData = jsonDecode(treeResponse.body); + final tree = treeData['tree'] as List; + + final jsonFiles = tree.where((item) { + final path = item['path'] as String; + return path.startsWith('mock-responses/') && path.endsWith('.json'); + }).toList(); + + for (final file in jsonFiles) { + final path = file['path'] as String; + final rawUrl = Uri.parse( + 'https://raw.githubusercontent.com/FirebaseExtended/vertexai-sdk-test-data/main/$path', + ); + final response = await http.get(rawUrl); + if (response.statusCode != 200) { + continue; + } + + final jsonData = jsonDecode(response.body); + + final isVertex = path.contains('vertexai'); + final serializer = + isVertex ? VertexSerialization() : DeveloperSerialization(); + + try { + if (path.contains('generate-images')) { + if (path.contains('gcs')) { + parseImagenGenerationResponse(jsonData); + } else { + parseImagenGenerationResponse(jsonData); + } + } else if (path.contains('total-tokens') || path.contains('token')) { + if (jsonData is Map && + (jsonData.containsKey('totalTokens') || + jsonData.containsKey('error'))) { + serializer.parseCountTokensResponse(jsonData); + } else { + serializer.parseGenerateContentResponse(jsonData); + } + } else { + serializer.parseGenerateContentResponse(jsonData); + } + + if (path.contains('failure') && !path.contains('success')) { + fail('Expected parsing to fail for $path, but it succeeded.'); + } + } catch (e) { + if (path.contains('failure') && !path.contains('success')) { + // Expected to fail + expect( + e, + isA(), + reason: 'Expected an Exception but got $e for $path', + ); + } else { + fail('Failed to parse success file $path: $e'); + } + } + } + }); + }); +} diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index 79194bc216c8..f1419386b77e 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: cloud_functions_platform_interface: ^5.8.9 cloud_functions_web: ^5.1.2 collection: ^1.15.0 + firebase_ai: ^3.8.0 firebase_analytics: ^12.1.2 firebase_analytics_platform_interface: ^5.0.6 firebase_analytics_web: ^0.6.1+2