From 500206eb4918899cbac49b87aaabbb506ecfadf1 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Thu, 3 Jul 2025 16:02:12 -0400 Subject: [PATCH 1/3] [FirebaseAI] Add support for Grounding with Google Search --- .../firebase_ai/lib/firebase_ai.dart | 2 +- .../firebase_ai/firebase_ai/lib/src/api.dart | 307 ++++++++++++++++-- .../firebase_ai/lib/src/base_model.dart | 2 +- .../firebase_ai/lib/src/developer/api.dart | 2 +- .../src/{function_calling.dart => tool.dart} | 49 ++- .../firebase_ai/test/api_test.dart | 286 ++++++++++++++++ .../firebase_ai/test/model_test.dart | 16 + 7 files changed, 634 insertions(+), 30 deletions(-) rename packages/firebase_ai/firebase_ai/lib/src/{function_calling.dart => tool.dart} (73%) diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index dbc95e1bca24..41ff41c07114 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -51,7 +51,7 @@ export 'src/error.dart' ServerException, UnsupportedUserLocation; export 'src/firebase_ai.dart' show FirebaseAI; -export 'src/function_calling.dart' +export 'src/tool.dart' show FunctionCallingConfig, FunctionCallingMode, diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index afe69f6dce52..93e3392040d3 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -14,7 +14,7 @@ import 'content.dart'; import 'error.dart'; -import 'function_calling.dart' show Tool, ToolConfig; +import 'tool.dart' show Tool, ToolConfig; import 'schema.dart'; /// Response for Count Tokens @@ -187,7 +187,8 @@ final class Candidate { // TODO: token count? // ignore: public_member_api_docs Candidate(this.content, this.safetyRatings, this.citationMetadata, - this.finishReason, this.finishMessage); + this.finishReason, this.finishMessage, + {this.groundingMetadata}); /// Generated content returned from the model. final Content content; @@ -212,6 +213,9 @@ final class Candidate { /// Message for finish reason. final String? finishMessage; + /// Metadata returned to the client when grounding is enabled. + final GroundingMetadata? groundingMetadata; + /// The concatenation of the text parts of [content], if any. /// /// If this candidate was finished for a reason of [FinishReason.recitation] @@ -243,6 +247,144 @@ final class Candidate { } } +/// Represents a specific segment within a [Content], often used to pinpoint +/// the exact location of text or data that grounding information refers to. +final class Segment { + Segment( + {required this.partIndex, + required this.startIndex, + required this.endIndex, + required this.text}); + + /// The zero-based index of the [Part] object within the `parts` array of its + /// parent [Content] object. + /// + /// This identifies which part of the content the segment belongs to. + final int partIndex; + + /// The zero-based start index of the segment within the specified [Part], + /// measured in UTF-8 bytes. + /// + /// This offset is inclusive, starting from 0 at the beginning of the + /// part's content. + final int startIndex; + + /// The zero-based end index of the segment within the specified [Part], + /// measured in UTF-8 bytes. + /// + /// This offset is exclusive, meaning the character at this index is not + /// included in the segment. + final int endIndex; + + /// The text corresponding to the segment from the response. + final String text; +} + +/// A grounding chunk sourced from the web. +final class WebGroundingChunk { + WebGroundingChunk({this.uri, this.title, this.domain}); + + /// The URI of the retrieved web page. + final String? uri; + + /// The title of the retrieved web page. + final String? title; + + /// The domain of the original URI from which the content was retrieved. + /// + /// This field is only populated when using the Vertex AI Gemini API. + final String? domain; +} + +/// Represents a chunk of retrieved data that supports a claim in the model's +/// response. +/// +/// This is part of the grounding information provided when grounding is +/// enabled. +final class GroundingChunk { + GroundingChunk({this.web}); + + /// Contains details if the grounding chunk is from a web source. + final WebGroundingChunk? web; +} + +/// Provides information about how a specific segment of the model's response +/// is supported by the retrieved grounding chunks. +final class GroundingSupport { + GroundingSupport( + {required this.segment, required this.groundingChunkIndices}); + + /// Specifies the segment of the model's response content that this + /// grounding support pertains to. + final Segment segment; + + /// A list of indices that refer to specific [GroundingChunk]s within the + /// [GroundingMetadata.groundingChunks] array. + /// + /// These referenced chunks are the sources that + /// support the claim made in the associated `segment` of the response. + /// For example, an array `[1, 3, 4]` + /// means that `groundingChunks[1]`, `groundingChunks[3]`, and + /// `groundingChunks[4]` are the + /// retrieved content supporting this part of the response. + final List groundingChunkIndices; +} + +/// Google Search entry point for web searches. +final class SearchEntryPoint { + SearchEntryPoint({required this.renderedContent}); + + /// An HTML/CSS snippet that **must** be embedded in an app to display a + /// Google Search entry point for follow-up web searches related to the + /// model's "Grounded Response". + /// + /// To ensure proper rendering, it's recommended to display this content + /// within a `WebView`. + final String renderedContent; +} + +/// Metadata returned to the client when grounding is enabled. +/// +/// > Important: If using Grounding with Google Search, you are required to +/// comply with the "Grounding with Google Search" usage requirements for your +/// chosen API provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). +final class GroundingMetadata { + GroundingMetadata( + {this.searchEntryPoint, + required this.groundingChunks, + required this.groundingSupport, + required this.webSearchQueries}); + + /// Google Search entry point for web searches. + /// + /// This contains an HTML/CSS snippet that **must** be embedded in an app to + // display a Google Search entry point for follow-up web searches related to + // the model's "Grounded Response". + final SearchEntryPoint? searchEntryPoint; + + /// A list of [GroundingChunk]s. + /// + /// Each chunk represents a piece of retrieved content (e.g., from a web + /// page) that the model used to ground its response. + final List groundingChunks; + + /// A list of [GroundingSupport]s. + /// + /// Each object details how specific segments of the + /// model's response are supported by the `groundingChunks`. + final List groundingSupport; + + /// A list of web search queries that the model performed to gather the + /// grounding information. + /// + /// These can be used to allow users to explore the search results + /// themselves. + final List webSearchQueries; +} + /// Safety rating for a piece of content. /// /// The safety rating contains the category of harm and the harm probability @@ -1027,29 +1169,33 @@ Candidate _parseCandidate(Object? jsonObject) { } return Candidate( - jsonObject.containsKey('content') - ? parseContent(jsonObject['content'] as Object) - : Content(null, []), - switch (jsonObject) { - {'safetyRatings': final List safetyRatings} => - safetyRatings.map(_parseSafetyRating).toList(), - _ => null - }, - switch (jsonObject) { - {'citationMetadata': final Object citationMetadata} => - _parseCitationMetadata(citationMetadata), - _ => null - }, - switch (jsonObject) { - {'finishReason': final Object finishReason} => - FinishReason._parseValue(finishReason), - _ => null - }, - switch (jsonObject) { - {'finishMessage': final String finishMessage} => finishMessage, - _ => null - }, - ); + jsonObject.containsKey('content') + ? parseContent(jsonObject['content'] as Object) + : Content(null, []), + switch (jsonObject) { + {'safetyRatings': final List safetyRatings} => + safetyRatings.map(_parseSafetyRating).toList(), + _ => null + }, + switch (jsonObject) { + {'citationMetadata': final Object citationMetadata} => + _parseCitationMetadata(citationMetadata), + _ => null + }, + switch (jsonObject) { + {'finishReason': final Object finishReason} => + FinishReason._parseValue(finishReason), + _ => null + }, + switch (jsonObject) { + {'finishMessage': final String finishMessage} => finishMessage, + _ => null + }, + groundingMetadata: switch (jsonObject) { + {'groundingMetadata': final Object groundingMetadata} => + _parseGroundingMetadata(groundingMetadata), + _ => null + }); } PromptFeedback _parsePromptFeedback(Object jsonObject) { @@ -1163,3 +1309,114 @@ Citation _parseCitationSource(Object? jsonObject) { jsonObject['license'] as String?, ); } + +GroundingMetadata _parseGroundingMetadata(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('GroundingMetadata', jsonObject); + } + + final searchEntryPoint = switch (jsonObject) { + {'searchEntryPoint': final Object? searchEntryPoint} => + _parseSearchEntryPoint(searchEntryPoint), + _ => null, + }; + final groundingChunks = switch (jsonObject) { + {'groundingChunks': final List groundingChunks} => + groundingChunks.map(_parseGroundingChunk).toList(), + _ => null, + } ?? + []; + // Filters out null elements, which are returned from _parseGroundingSupport when + // segment is null. + final groundingSupport = switch (jsonObject) { + {'groundingSupport': final List groundingSupport} => + groundingSupport + .map(_parseGroundingSupport) + .whereType() + .toList(), + _ => null, + } ?? + []; + final webSearchQueries = switch (jsonObject) { + {'webSearchQueries': final List? webSearchQueries} => + webSearchQueries, + _ => null, + } ?? + []; + + return GroundingMetadata( + searchEntryPoint: searchEntryPoint, + groundingChunks: groundingChunks, + groundingSupport: groundingSupport, + webSearchQueries: webSearchQueries); +} + +Segment _parseSegment(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('Segment', jsonObject); + } + + return Segment( + partIndex: (jsonObject['partIndex'] as int?) ?? 0, + startIndex: (jsonObject['startIndex'] as int?) ?? 0, + endIndex: (jsonObject['endIndex'] as int?) ?? 0, + text: (jsonObject['text'] as String?) ?? ''); +} + +WebGroundingChunk _parseWebGroundingChunk(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('WebGroundingChunk', jsonObject); + } + + return WebGroundingChunk( + uri: jsonObject['uri'] as String?, + title: jsonObject['title'] as String?, + domain: jsonObject['domain'] as String?, + ); +} + +GroundingChunk _parseGroundingChunk(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('GroundingChunk', jsonObject); + } + + return GroundingChunk( + web: jsonObject['web'] != null + ? _parseWebGroundingChunk(jsonObject['web']) + : null, + ); +} + +GroundingSupport? _parseGroundingSupport(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('GroundingSupport', jsonObject); + } + + final segment = switch (jsonObject) { + {'segment': final Object? segment} => _parseSegment(segment), + _ => null, + }; + if (segment == null) { + return null; + } + + return GroundingSupport( + segment: segment, + groundingChunkIndices: + (jsonObject['groundingChunkIndices'] as List?) ?? []); +} + +SearchEntryPoint _parseSearchEntryPoint(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('SearchEntryPoint', jsonObject); + } + + final renderedContent = jsonObject['renderedContent'] as String?; + if (renderedContent == null) { + throw unhandledFormat('SearchEntryPoint', jsonObject); + } + + return SearchEntryPoint( + renderedContent: renderedContent, + ); +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart index f63c883e7015..6d869b0208bb 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart @@ -28,7 +28,7 @@ import 'api.dart'; import 'client.dart'; import 'content.dart'; import 'developer/api.dart'; -import 'function_calling.dart'; +import 'tool.dart'; import 'imagen_api.dart'; import 'imagen_content.dart'; import 'live_api.dart'; diff --git a/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart b/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart index e1a56f30a894..fb106dc4029d 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart @@ -33,7 +33,7 @@ import '../api.dart' createUsageMetadata; import '../content.dart' show Content, FunctionCall, Part, TextPart; import '../error.dart'; -import '../function_calling.dart' show Tool, ToolConfig; +import '../tool.dart' show Tool, ToolConfig; HarmProbability _parseHarmProbability(Object jsonObject) => switch (jsonObject) { diff --git a/packages/firebase_ai/firebase_ai/lib/src/function_calling.dart b/packages/firebase_ai/firebase_ai/lib/src/tool.dart similarity index 73% rename from packages/firebase_ai/firebase_ai/lib/src/function_calling.dart rename to packages/firebase_ai/firebase_ai/lib/src/tool.dart index f70bff0b3ff7..184b6a18d0f5 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/function_calling.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/tool.dart @@ -21,12 +21,33 @@ import 'schema.dart'; /// knowledge and scope of the model. final class Tool { // ignore: public_member_api_docs - Tool._(this._functionDeclarations); + Tool._(this._functionDeclarations, this._googleSearch); /// Returns a [Tool] instance with list of [FunctionDeclaration]. static Tool functionDeclarations( List functionDeclarations) { - return Tool._(functionDeclarations); + return Tool._(functionDeclarations, null); + } + + /// Creates a tool that allows the model to use Grounding with Google Search. + /// + /// Grounding with Google Search can be used to allow the model to connect to + /// Google Search to access and incorporate up-to-date information from the + /// web into it's responses. + /// + /// When using this feature, you are required to comply with the + /// "Grounding with Google Search" usage requirements for your chosen API + /// provider: + /// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) + /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) + /// section within the Service Specific Terms). + /// + /// - [googleSearch]: An empty [GoogleSearch] object. The presence of this + /// object in the list of tools enables the model to use Google Search. + /// + /// Returns a `Tool` configured for Google Search. + static Tool googleSearch({GoogleSearch googleSearch = const GoogleSearch()}) { + return Tool._(null, googleSearch); } /// A list of `FunctionDeclarations` available to the model that can be used @@ -39,14 +60,38 @@ final class Tool { /// with the role "function" generation context for the next model turn. final List? _functionDeclarations; + /// A tool that allows the generative model to connect to Google Search to + /// access and incorporate up-to-date information from the web into its + /// responses. + final GoogleSearch? _googleSearch; + /// Convert to json object. Map toJson() => { if (_functionDeclarations case final _functionDeclarations?) 'functionDeclarations': _functionDeclarations.map((f) => f.toJson()).toList(), + if (_googleSearch case final _googleSearch?) + 'googleSearch': _googleSearch.toJson() }; } +/// A tool that allows the generative model to connect to Google Search to +/// access and incorporate up-to-date information from the web into its +/// responses. +/// +/// When using this feature, you are required to comply with the +/// "Grounding with Google Search" usage requirements for your chosen API +/// provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). +final class GoogleSearch { + const GoogleSearch(); + + // Convert to json object. + Map toJson() => {}; +} + /// Structured representation of a function declaration as defined by the /// [OpenAPI 3.03 specification](https://spec.openapis.org/oas/v3.0.3). /// diff --git a/packages/firebase_ai/firebase_ai/test/api_test.dart b/packages/firebase_ai/firebase_ai/test/api_test.dart index e00b8090325a..5104c655ac2a 100644 --- a/packages/firebase_ai/firebase_ai/test/api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/api_test.dart @@ -393,6 +393,27 @@ void main() { }); }); + group('GroundingMetadata', () { + test('constructor initializes fields correctly', () { + final searchEntryPoint = SearchEntryPoint(renderedContent: '
'); + final groundingChunk = GroundingChunk(web: WebGroundingChunk(uri: 'uri')); + final groundingSupport = GroundingSupport( + segment: Segment(startIndex: 0, partIndex: 0, endIndex: 1, text: ''), + groundingChunkIndices: [0]); + final metadata = GroundingMetadata( + searchEntryPoint: searchEntryPoint, + groundingChunks: [groundingChunk], + groundingSupport: [groundingSupport], + webSearchQueries: ['web query'], + ); + + expect(metadata.searchEntryPoint, same(searchEntryPoint)); + expect(metadata.groundingChunks.first, same(groundingChunk)); + expect(metadata.groundingSupport.first, same(groundingSupport)); + expect(metadata.webSearchQueries, ['web query']); + }); + }); + group('GenerationConfig & BaseGenerationConfig', () { test('GenerationConfig toJson with all fields', () { final schema = Schema.object(properties: {}); @@ -572,6 +593,271 @@ void main() { expect(response.usageMetadata!.candidatesTokensDetails, hasLength(1)); }); + group('groundingMetadata parsing', () { + test('parses valid response with full grounding metadata', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a grounded response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'webSearchQueries': ['query1', 'query2'], + 'searchEntryPoint': {'renderedContent': '
'}, + 'groundingChunks': [ + { + 'web': { + 'uri': 'http://example.com/1', + 'title': 'Example Page 1', + } + } + ], + 'groundingSupport': [ + { + 'segment': { + 'startIndex': 5, + 'endIndex': 13, + 'text': 'grounded' + }, + 'groundingChunkIndices': [0], + } + ] + } + } + ] + }; + + final response = + VertexSerialization().parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + expect(groundingMetadata!.webSearchQueries, + equals(['query1', 'query2'])); + expect(groundingMetadata.searchEntryPoint?.renderedContent, + '
'); + + final groundingChunk = groundingMetadata.groundingChunks.first; + expect(groundingChunk.web?.uri, "http://example.com/1"); + expect(groundingChunk.web?.title, "Example Page 1"); + expect(groundingChunk.web?.domain, isNull); + + final groundingSupport = groundingMetadata.groundingSupport.first; + expect(groundingSupport.segment.startIndex, 5); + expect(groundingSupport.segment.endIndex, 13); + expect(groundingSupport.segment.partIndex, 0); + expect(groundingSupport.segment.text, 'grounded'); + expect(groundingSupport.groundingChunkIndices, [0]); + }); + + test('parses with empty or minimal grounding sub-components', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a grounded response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'webSearchQueries': ['query1', 'query2'], + 'groundingChunks': [ + {}, + {'web': {}}, + ], + 'groundingSupport': [ + {}, + { + 'groundingChunkIndices': [0], + }, + { + 'groundingChunkIndices': [0], + 'segment': { + 'startIndex': 5, + 'partIndex': 0, + 'endIndex': 13, + 'text': 'grounded' + }, + } + ] + } + } + ] + }; + + final response = + VertexSerialization().parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + expect(groundingMetadata!.webSearchQueries, + equals(['query1', 'query2'])); + + expect(groundingMetadata.searchEntryPoint, isNull); + expect(groundingMetadata.groundingChunks[0].web, isNull); + + expect(groundingMetadata.groundingChunks[1].web, isNotNull); + expect(groundingMetadata.groundingChunks[1].web?.uri, isNull); + expect(groundingMetadata.groundingChunks[1].web?.title, isNull); + expect(groundingMetadata.groundingChunks[1].web?.domain, isNull); + + expect( + groundingMetadata.groundingSupport, + hasLength( + 1)); // GroundingSupport's without a segment are filtered out + final firstSupport = groundingMetadata.groundingSupport[0]; + expect(firstSupport.segment, isNotNull); + expect(firstSupport.groundingChunkIndices, isNotEmpty); + }); + + test( + 'throws FormatException if renderedContent is missing in searchEntryPoint', + () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a grounded response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': {'searchEntryPoint': {}} + } + ] + }; + + expect( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( + (e) => e.message, 'message', contains('SearchEntryPoint')))); + }); + + test( + 'parses groundingMetadata with all optional fields null/missing and empty lists', + () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'Test'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + // searchEntryPoint is missing + // groundingChunks is missing (defaults to []) + // groundingSupport is missing (defaults to []) + // webSearchQueries is missing (defaults to []) + } + } + ] + }; + final response = + VertexSerialization().parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + expect(groundingMetadata!.searchEntryPoint, isNull); + expect(groundingMetadata.groundingChunks, isEmpty); + expect(groundingMetadata.groundingSupport, isEmpty); + expect(groundingMetadata.webSearchQueries, isEmpty); + }); + + test('throws FormatException for invalid item in groundingChunks', () { + final json = { + 'candidates': [ + { + 'groundingMetadata': { + 'groundingChunks': ['not_a_map'] + } + } + ] + }; + expect( + () => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA().having( + (e) => e.message, 'message', contains('GroundingChunk')))); + }); + + test('throws FormatException for invalid item in groundingSupport', () { + final json = { + 'candidates': [ + { + 'groundingMetadata': { + 'groundingSupport': ['not_a_map'] + } + } + ] + }; + expect( + () => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA().having( + (e) => e.message, 'message', contains('GroundingSupport')))); + }); + + test('throws FormatException for invalid searchEntryPoint structure', + () { + final json = { + 'candidates': [ + { + 'groundingMetadata': {'searchEntryPoint': 'not_a_map'} + } + ] + }; + expect( + () => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA().having( + (e) => e.message, 'message', contains('SearchEntryPoint')))); + }); + + test( + 'throws FormatException for invalid segment structure in groundingSupport', + () { + final json = { + 'candidates': [ + { + 'groundingMetadata': { + 'groundingSupport': [ + {'segment': 'not_a_map'} + ] + } + } + ] + }; + expect( + () => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA() + .having((e) => e.message, 'message', contains('Segment')))); + }); + + test( + 'throws FormatException for invalid web structure in groundingChunk', + () { + final json = { + 'candidates': [ + { + 'groundingMetadata': { + 'groundingChunks': [ + {'web': 'not_a_map'} + ] + } + } + ] + }; + expect( + () => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA().having( + (e) => e.message, 'message', contains('WebGroundingChunk')))); + }); + }); + test('parses JSON with no candidates (empty list)', () { final json = {'candidates': []}; final response = diff --git a/packages/firebase_ai/firebase_ai/test/model_test.dart b/packages/firebase_ai/firebase_ai/test/model_test.dart index 2ddf4d55406c..860b8e19ba7c 100644 --- a/packages/firebase_ai/firebase_ai/test/model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/model_test.dart @@ -268,6 +268,22 @@ void main() { ); }); + test('can pass a google search tool', () async { + final (client, model) = createModel( + tools: [Tool.googleSearch()], + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + {'googleSearch': {}}, + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }); + test('can override tools and function calling config', () async { final (client, model) = createModel(); const prompt = 'Some prompt'; From fcd5fa05588eb24bfa6115f73807cec295ffd08b Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 9 Jul 2025 14:23:53 -0400 Subject: [PATCH 2/3] fix formatting issues --- .../firebase_ai/firebase_ai/lib/firebase_ai.dart | 14 +++++++------- packages/firebase_ai/firebase_ai/lib/src/api.dart | 8 +++++++- .../firebase_ai/lib/src/base_model.dart | 2 +- packages/firebase_ai/firebase_ai/lib/src/tool.dart | 3 ++- .../firebase_ai/firebase_ai/test/api_test.dart | 4 ++-- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 41ff41c07114..0587c156f9a5 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -51,13 +51,6 @@ export 'src/error.dart' ServerException, UnsupportedUserLocation; export 'src/firebase_ai.dart' show FirebaseAI; -export 'src/tool.dart' - show - FunctionCallingConfig, - FunctionCallingMode, - FunctionDeclaration, - Tool, - ToolConfig; export 'src/imagen_api.dart' show ImagenSafetySettings, @@ -78,3 +71,10 @@ export 'src/live_api.dart' LiveServerResponse; export 'src/live_session.dart' show LiveSession; export 'src/schema.dart' show Schema, SchemaType; +export 'src/tool.dart' + show + FunctionCallingConfig, + FunctionCallingMode, + FunctionDeclaration, + Tool, + ToolConfig; diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index 6451f39ec161..7a482c087f1d 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -14,8 +14,8 @@ import 'content.dart'; import 'error.dart'; -import 'tool.dart' show Tool, ToolConfig; import 'schema.dart'; +import 'tool.dart' show Tool, ToolConfig; /// Response for Count Tokens final class CountTokensResponse { @@ -259,6 +259,7 @@ final class Candidate { /// Represents a specific segment within a [Content], often used to pinpoint /// the exact location of text or data that grounding information refers to. final class Segment { + // ignore: public_member_api_docs Segment( {required this.partIndex, required this.startIndex, @@ -291,6 +292,7 @@ final class Segment { /// A grounding chunk sourced from the web. final class WebGroundingChunk { + // ignore: public_member_api_docs WebGroundingChunk({this.uri, this.title, this.domain}); /// The URI of the retrieved web page. @@ -311,6 +313,7 @@ final class WebGroundingChunk { /// This is part of the grounding information provided when grounding is /// enabled. final class GroundingChunk { + // ignore: public_member_api_docs GroundingChunk({this.web}); /// Contains details if the grounding chunk is from a web source. @@ -320,6 +323,7 @@ final class GroundingChunk { /// Provides information about how a specific segment of the model's response /// is supported by the retrieved grounding chunks. final class GroundingSupport { + // ignore: public_member_api_docs GroundingSupport( {required this.segment, required this.groundingChunkIndices}); @@ -341,6 +345,7 @@ final class GroundingSupport { /// Google Search entry point for web searches. final class SearchEntryPoint { + // ignore: public_member_api_docs SearchEntryPoint({required this.renderedContent}); /// An HTML/CSS snippet that **must** be embedded in an app to display a @@ -361,6 +366,7 @@ final class SearchEntryPoint { /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) /// section within the Service Specific Terms). final class GroundingMetadata { + // ignore: public_member_api_docs GroundingMetadata( {this.searchEntryPoint, required this.groundingChunks, diff --git a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart index 6d869b0208bb..413af8ba49eb 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart @@ -28,11 +28,11 @@ import 'api.dart'; import 'client.dart'; import 'content.dart'; import 'developer/api.dart'; -import 'tool.dart'; import 'imagen_api.dart'; import 'imagen_content.dart'; import 'live_api.dart'; import 'live_session.dart'; +import 'tool.dart'; import 'vertex_version.dart'; part 'generative_model.dart'; diff --git a/packages/firebase_ai/firebase_ai/lib/src/tool.dart b/packages/firebase_ai/firebase_ai/lib/src/tool.dart index 184b6a18d0f5..394cb555e7af 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/tool.dart @@ -86,9 +86,10 @@ final class Tool { /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) /// section within the Service Specific Terms). final class GoogleSearch { + // ignore: public_member_api_docs const GoogleSearch(); - // Convert to json object. + /// Convert to json object. Map toJson() => {}; } diff --git a/packages/firebase_ai/firebase_ai/test/api_test.dart b/packages/firebase_ai/firebase_ai/test/api_test.dart index 792b753e199a..28d7f819a86b 100644 --- a/packages/firebase_ai/firebase_ai/test/api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/api_test.dart @@ -663,8 +663,8 @@ void main() { '
'); final groundingChunk = groundingMetadata.groundingChunks.first; - expect(groundingChunk.web?.uri, "http://example.com/1"); - expect(groundingChunk.web?.title, "Example Page 1"); + expect(groundingChunk.web?.uri, 'http://example.com/1'); + expect(groundingChunk.web?.title, 'Example Page 1'); expect(groundingChunk.web?.domain, isNull); final groundingSupport = groundingMetadata.groundingSupport.first; From b1c7798df38f3acdcd03437bb11bcad25b80daaa Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 9 Jul 2025 14:48:01 -0400 Subject: [PATCH 3/3] fix tests --- packages/firebase_ai/firebase_ai/test/api_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/test/api_test.dart b/packages/firebase_ai/firebase_ai/test/api_test.dart index 28d7f819a86b..a21b17a0ee56 100644 --- a/packages/firebase_ai/firebase_ai/test/api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/api_test.dart @@ -756,7 +756,7 @@ void main() { expect( () => VertexSerialization() .parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + throwsA(isA().having( (e) => e.message, 'message', contains('SearchEntryPoint')))); }); @@ -804,7 +804,7 @@ void main() { }; expect( () => VertexSerialization().parseGenerateContentResponse(json), - throwsA(isA().having( + throwsA(isA().having( (e) => e.message, 'message', contains('GroundingChunk')))); }); @@ -820,7 +820,7 @@ void main() { }; expect( () => VertexSerialization().parseGenerateContentResponse(json), - throwsA(isA().having( + throwsA(isA().having( (e) => e.message, 'message', contains('GroundingSupport')))); }); @@ -835,7 +835,7 @@ void main() { }; expect( () => VertexSerialization().parseGenerateContentResponse(json), - throwsA(isA().having( + throwsA(isA().having( (e) => e.message, 'message', contains('SearchEntryPoint')))); }); @@ -855,7 +855,7 @@ void main() { }; expect( () => VertexSerialization().parseGenerateContentResponse(json), - throwsA(isA() + throwsA(isA() .having((e) => e.message, 'message', contains('Segment')))); }); @@ -875,7 +875,7 @@ void main() { }; expect( () => VertexSerialization().parseGenerateContentResponse(json), - throwsA(isA().having( + throwsA(isA().having( (e) => e.message, 'message', contains('WebGroundingChunk')))); }); });