diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index c978b210a066..bb42d139229c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -29,6 +29,7 @@ import 'pages/json_schema_page.dart'; import 'pages/multimodal_page.dart'; import 'pages/schema_page.dart'; import 'pages/server_template_page.dart'; +import 'pages/grounding_page.dart'; import 'pages/token_count_page.dart'; void main() async { @@ -172,6 +173,11 @@ class _HomeScreenState extends State { title: 'Server Template', useVertexBackend: useVertexBackend, ); + case 10: + return GroundingPage( + title: 'Grounding', + useVertexBackend: useVertexBackend, + ); default: // Fallback to the first page in case of an unexpected index @@ -299,6 +305,13 @@ class _HomeScreenState extends State { label: 'Server', tooltip: 'Server Template', ), + BottomNavigationBarItem( + icon: Icon( + Icons.location_on, + ), + label: 'Grounding', + tooltip: 'Search & Maps Grounding', + ), ], currentIndex: _selectedIndex, onTap: _onItemTapped, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart new file mode 100644 index 000000000000..8456be1a9e4c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart @@ -0,0 +1,303 @@ +// 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 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class GroundingPage extends StatefulWidget { + const GroundingPage({ + super.key, + required this.title, + required this.useVertexBackend, + }); + + final String title; + final bool useVertexBackend; + + @override + State createState() => _GroundingPageState(); +} + +class _GroundingPageState extends State { + GenerativeModel? _model; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final TextEditingController _latController = TextEditingController(); + final TextEditingController _lngController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + + bool _loading = false; + bool _enableSearchGrounding = false; + bool _enableMapsGrounding = false; + + @override + void initState() { + super.initState(); + _latController.text = '37.422'; // Default Googleplex lat + _lngController.text = '-122.084'; // Default Googleplex lng + } + + void _initializeModel() { + List tools = []; + ToolConfig? toolConfig; + + if (_enableSearchGrounding) { + tools.add(Tool.googleSearch()); + } + + if (_enableMapsGrounding) { + tools.add(Tool.googleMaps()); + + final lat = double.tryParse(_latController.text); + final lng = double.tryParse(_lngController.text); + + if (lat != null && lng != null) { + toolConfig = ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: lat, longitude: lng), + ), + ); + } + } + + final aiProvider = widget.useVertexBackend + ? FirebaseAI.vertexAI(auth: FirebaseAuth.instance) + : FirebaseAI.googleAI(auth: FirebaseAuth.instance); + + _model = aiProvider.generativeModel( + model: 'gemini-2.5-flash', + tools: tools.isNotEmpty ? tools : null, + toolConfig: toolConfig, + ); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 750), + curve: Curves.easeOutCirc, + ), + ); + } + + Future _sendPrompt(String message) async { + if (message.isEmpty) return; + + _initializeModel(); // Re-initialize before sending to capture current toggles + + setState(() { + _loading = true; + }); + + try { + _messages.add(MessageData(text: message, fromUser: true)); + + final response = await _model?.generateContent([Content.text(message)]); + + var text = response?.text; + + // Extract grounding metadata to display + final groundingMetadata = + response?.candidates.firstOrNull?.groundingMetadata; + if (groundingMetadata != null) { + final chunks = groundingMetadata.groundingChunks.map((chunk) { + if (chunk.web != null) { + final title = chunk.web!.title ?? chunk.web!.uri; + return '- [$title](${chunk.web!.uri})'; + } + if (chunk.maps != null) { + final title = chunk.maps!.title ?? chunk.maps!.uri; + return '- [${title ?? 'Maps Result'}](${chunk.maps!.uri ?? ''})'; + } + return '- Unknown chunk'; + }).join('\n'); + + if (chunks.isNotEmpty) { + text = '$text\n\n**Grounding Sources:**\n$chunks'; + } + } + + _messages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } + } catch (e) { + _showError(e.toString()); + } finally { + if (mounted) { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + _scrollDown(); + } + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: SwitchListTile( + title: const Text( + 'Search Grounding', + style: TextStyle(fontSize: 12), + ), + value: _enableSearchGrounding, + onChanged: (bool value) { + setState(() { + _enableSearchGrounding = value; + }); + }, + ), + ), + Expanded( + child: SwitchListTile( + title: const Text( + 'Maps Grounding', + style: TextStyle(fontSize: 12), + ), + value: _enableMapsGrounding, + onChanged: (bool value) { + setState(() { + _enableMapsGrounding = value; + }); + }, + ), + ), + ], + ), + if (_enableMapsGrounding) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _latController, + decoration: + const InputDecoration(labelText: 'Latitude'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _lngController, + decoration: + const InputDecoration(labelText: 'Longitude'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + final message = _messages[idx]; + return MessageWidget( + text: message.text, + isFromUser: message.fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + onSubmitted: _sendPrompt, + decoration: const InputDecoration( + hintText: 'Enter a prompt...', + ), + ), + ), + const SizedBox.square(dimension: 15), + if (!_loading) + IconButton( + onPressed: () { + _sendPrompt(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart index c10c6e243323..ad351e4778c7 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart @@ -260,6 +260,18 @@ class _ServerTemplatePageState extends State { ), tooltip: 'URL Context', ), + IconButton( + onPressed: () async { + await _serverTemplateMapsGrounding( + _textController.text, + ); + }, + icon: Icon( + Icons.map, + color: Theme.of(context).colorScheme.primary, + ), + tooltip: 'Maps Grounding', + ), IconButton( onPressed: () async { await _sendServerTemplateMessage(_textController.text); @@ -356,6 +368,50 @@ class _ServerTemplatePageState extends State { }); } + Future _serverTemplateMapsGrounding(String message) async { + await _handleServerTemplateMessage(message, (message) async { + var response = await _templateGenerativeModel + // ignore: experimental_member_use + ?.generateContent( + 'cj-googlemaps', + inputs: {'question': message}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.422, longitude: -122.084), // Googleplex + ), + ), + ); + + final candidate = response?.candidates.first; + if (candidate == null) { + _messages.add(MessageData(text: 'No response', fromUser: false)); + } else { + final responseText = candidate.text ?? ''; + final groundingMetadata = candidate.groundingMetadata; + + final buffer = StringBuffer(responseText); + if (groundingMetadata != null) { + buffer.writeln('\n\n--- Grounding Metadata ---'); + buffer.writeln('Grounding Chunks:'); + for (final chunk in groundingMetadata.groundingChunks) { + if (chunk.web != null) { + buffer.writeln(' - Web Chunk:'); + buffer.writeln(' - Title: ${chunk.web!.title}'); + buffer.writeln(' - URI: ${chunk.web!.uri}'); + } + if (chunk.maps != null) { + buffer.writeln(' - Maps Chunk:'); + buffer.writeln(' - Title: ${chunk.maps!.title}'); + buffer.writeln(' - URI: ${chunk.maps!.uri}'); + } + } + } + + _messages.add(MessageData(text: buffer.toString(), fromUser: false)); + } + }); + } + Future _serverTemplateAutoFunctionCall(String message) async { await _handleServerTemplateMessage(message, (message) async { // Inputs are no longer passed during sendMessage diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 730c8516c045..5702945342f1 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -22,6 +22,8 @@ export 'src/api.dart' FinishReason, GenerateContentResponse, GenerationConfig, + GoogleMapsGroundingChunk, + GroundingChunk, ThinkingConfig, ThinkingLevel, HarmBlockThreshold, @@ -32,7 +34,8 @@ export 'src/api.dart' ResponseModalities, SafetyRating, SafetySetting, - UsageMetadata; + UsageMetadata, + WebGroundingChunk; export 'src/base_model.dart' show GenerativeModel, @@ -128,5 +131,8 @@ export 'src/tool.dart' Tool, ToolConfig, GoogleSearch, + GoogleMaps, CodeExecution, - UrlContext; + UrlContext, + LatLng, + RetrievalConfig; diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index 653ef2097013..66d68afb0dd2 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -347,6 +347,23 @@ final class WebGroundingChunk { final String? domain; } +/// A grounding chunk sourced from Google Maps. +final class GoogleMapsGroundingChunk { + // ignore: public_member_api_docs + GoogleMapsGroundingChunk({this.uri, this.title, this.placeId}); + + /// The URI of the place. + final String? uri; + + /// The title of the place. + final String? title; + + /// This Place's resource name, in `places/{place_id}` format. + /// + /// This can be used to look up the place using the Google Maps API. + final String? placeId; +} + /// Represents a chunk of retrieved data that supports a claim in the model's /// response. /// @@ -354,10 +371,13 @@ final class WebGroundingChunk { /// enabled. final class GroundingChunk { // ignore: public_member_api_docs - GroundingChunk({this.web}); + GroundingChunk({this.web, this.maps}); /// Contains details if the grounding chunk is from a web source. final WebGroundingChunk? web; + + /// Contains details if the grounding chunk is from a Google Maps source. + final GoogleMapsGroundingChunk? maps; } /// Provides information about how a specific segment of the model's response @@ -1689,6 +1709,18 @@ WebGroundingChunk _parseWebGroundingChunk(Object? jsonObject) { ); } +GoogleMapsGroundingChunk _parseGoogleMapsGroundingChunk(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('GoogleMapsGroundingChunk', jsonObject); + } + + return GoogleMapsGroundingChunk( + uri: jsonObject['uri'] as String?, + title: jsonObject['title'] as String?, + placeId: jsonObject['placeId'] as String?, + ); +} + GroundingChunk _parseGroundingChunk(Object? jsonObject) { if (jsonObject is! Map) { throw unhandledFormat('GroundingChunk', jsonObject); @@ -1698,6 +1730,9 @@ GroundingChunk _parseGroundingChunk(Object? jsonObject) { web: jsonObject['web'] != null ? _parseWebGroundingChunk(jsonObject['web']) : null, + maps: jsonObject['maps'] != null + ? _parseGoogleMapsGroundingChunk(jsonObject['maps']) + : null, ); } diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart index 26fb90dabc7d..2efa63689dba 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart @@ -47,14 +47,13 @@ final class TemplateChatSession { Iterable content, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) _templateHistoryGenerateContent; + TemplateToolConfig? toolConfig}) _templateHistoryGenerateContent; final Stream Function( - Iterable content, String templateId, - {required Map inputs, - List? tools, - TemplateToolConfig? templateToolConfig}) - _templateHistoryGenerateContentStream; + Iterable content, String templateId, + {required Map inputs, + List? tools, + TemplateToolConfig? toolConfig}) _templateHistoryGenerateContentStream; final String _templateId; final Map _inputs; @@ -93,7 +92,7 @@ final class TemplateChatSession { _templateId, inputs: _inputs, tools: _tools, - templateToolConfig: _toolConfig, + toolConfig: _toolConfig, ); final functionCalls = response.functionCalls; @@ -161,7 +160,7 @@ final class TemplateChatSession { _templateId, inputs: _inputs, tools: _tools, - templateToolConfig: _toolConfig, + toolConfig: _toolConfig, ); final turnChunks = []; diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart index 41efccb4f460..0b94fc9eaea4 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart @@ -69,14 +69,15 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { /// Sends a "templateGenerateContent" API request for the configured model. @experimental Future generateContent(String templateId, - {required Map inputs}) => + {required Map inputs, + TemplateToolConfig? toolConfig}) => makeTemplateRequest( TemplateTask.templateGenerateContent, templateId, inputs, null, // history null, // tools - null, // toolConfig + toolConfig, _serializationStrategy.parseGenerateContentResponse); /// Generates a stream of content responding to [templateId] and [inputs]. @@ -85,14 +86,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { /// and waits for the response. @experimental Stream generateContentStream(String templateId, - {required Map inputs}) { + {required Map inputs, TemplateToolConfig? toolConfig}) { return streamTemplateRequest( TemplateTask.templateStreamGenerateContent, templateId, inputs, null, // history null, // tools - null, // toolConfig + toolConfig, _serializationStrategy.parseGenerateContentResponse); } @@ -103,14 +104,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { Iterable history, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) => + TemplateToolConfig? toolConfig}) => makeTemplateRequest( TemplateTask.templateGenerateContent, templateId, inputs, history, tools, - templateToolConfig, + toolConfig, _serializationStrategy.parseGenerateContentResponse); /// Generates a stream of content from a template with the given [templateId], @@ -120,14 +121,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { Iterable history, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) { + TemplateToolConfig? toolConfig}) { return streamTemplateRequest( TemplateTask.templateStreamGenerateContent, templateId, inputs, history, tools, - templateToolConfig, + toolConfig, _serializationStrategy.parseGenerateContentResponse); } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart index 31c60af4b82f..603875020c3b 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart @@ -14,6 +14,7 @@ import 'dart:async'; import '../schema.dart'; +import '../tool.dart'; /// A collection of template tools. final class TemplateTool { @@ -98,8 +99,14 @@ final class TemplateAutoFunctionDeclaration /// Config for template tools to use with server prompts. final class TemplateToolConfig { // ignore: public_member_api_docs - TemplateToolConfig(); + TemplateToolConfig({RetrievalConfig? retrievalConfig}) + : _retrievalConfig = retrievalConfig; + + final RetrievalConfig? _retrievalConfig; /// Convert to json object. - Map toJson() => {}; + Map toJson() => { + if (_retrievalConfig case final retrievalConfig?) + 'retrievalConfig': retrievalConfig.toJson(), + }; } diff --git a/packages/firebase_ai/firebase_ai/lib/src/tool.dart b/packages/firebase_ai/firebase_ai/lib/src/tool.dart index 29a7c7124c53..195db5f81836 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/tool.dart @@ -24,12 +24,12 @@ import 'schema.dart'; final class Tool { // ignore: public_member_api_docs Tool._(this._functionDeclarations, this._googleSearch, this._codeExecution, - this._urlContext); + this._urlContext, this._googleMaps); /// Returns a [Tool] instance with list of [FunctionDeclaration]. static Tool functionDeclarations( List functionDeclarations) { - return Tool._(functionDeclarations, null, null, null); + return Tool._(functionDeclarations, null, null, null, null); } /// Creates a tool that allows the model to use Grounding with Google Search. @@ -50,13 +50,13 @@ final class Tool { /// /// Returns a `Tool` configured for Google Search. static Tool googleSearch({GoogleSearch googleSearch = const GoogleSearch()}) { - return Tool._(null, googleSearch, null, null); + return Tool._(null, googleSearch, null, null, null); } /// Returns a [Tool] instance that enables the model to use Code Execution. static Tool codeExecution( {CodeExecution codeExecution = const CodeExecution()}) { - return Tool._(null, null, codeExecution, null); + return Tool._(null, null, codeExecution, null, null); } /// Creates a tool that allows you to provide additional context to the models @@ -73,7 +73,27 @@ final class Tool { /// is in Public Preview, which means that the feature is not subject to any SLA /// or deprecation policy and could change in backwards-incompatible ways. static Tool urlContext({UrlContext urlContext = const UrlContext()}) { - return Tool._(null, null, null, urlContext); + return Tool._(null, null, null, urlContext, null); + } + + /// Creates a tool that allows the model to use Grounding with Google Maps. + /// + /// Grounding with Google Maps can be used to allow the model to connect to + /// Google Maps to access and incorporate location-based information into its + /// responses. + /// + /// When using this feature, you are required to comply with the + /// "Grounding with Google Maps" usage requirements for your chosen API + /// provider: + /// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-maps) + /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) + /// section within the Service Specific Terms). + /// + /// - [googleMaps]: An empty [GoogleMaps] object. + /// + /// Returns a `Tool` configured for Google Maps. + static Tool googleMaps({GoogleMaps googleMaps = const GoogleMaps()}) { + return Tool._(null, null, null, null, googleMaps); } /// A list of `FunctionDeclarations` available to the model that can be used @@ -97,6 +117,10 @@ final class Tool { /// A tool that allows providing URL context to the model. final UrlContext? _urlContext; + /// A tool that allows the model to connect to Google Maps to access + /// location-based information. + final GoogleMaps? _googleMaps; + /// Returns a list of all [AutoFunctionDeclaration] objects /// found within the [_functionDeclarations] list. List get autoFunctionDeclarations { @@ -117,6 +141,8 @@ final class Tool { 'codeExecution': _codeExecution.toJson(), if (_urlContext case final _urlContext?) 'urlContext': _urlContext.toJson(), + if (_googleMaps case final _googleMaps?) + 'googleMaps': _googleMaps.toJson(), }; } @@ -138,6 +164,23 @@ final class GoogleSearch { Map toJson() => {}; } +/// A tool that allows a Gemini model to connect to Google Maps to access and +/// incorporate location-based information into its responses. +/// +/// Important: If using Grounding with Google Maps, you are required to comply +/// with the "Grounding with Google Maps" usage requirements for your chosen API +/// provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-maps) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). +final class GoogleMaps { + // ignore: public_member_api_docs + const GoogleMaps(); + + /// Convert to json object. + Map toJson() => {}; +} + /// A tool that allows you to provide additional context to the models in the /// form of public web URLs. By including URLs in your request, the Gemini /// model will access the content from those pages to inform and enhance its @@ -229,15 +272,57 @@ final class AutoFunctionDeclaration extends FunctionDeclaration { /// Config for tools to use with model. final class ToolConfig { // ignore: public_member_api_docs - ToolConfig({this.functionCallingConfig}); + ToolConfig({this.functionCallingConfig, this.retrievalConfig}); /// Config for function calling. final FunctionCallingConfig? functionCallingConfig; + /// Config that specifies information which can be used by tools during inference calls. + final RetrievalConfig? retrievalConfig; + /// Convert to json object. Map toJson() => { if (functionCallingConfig case final config?) 'functionCallingConfig': config.toJson(), + if (retrievalConfig case final config?) + 'retrievalConfig': config.toJson(), + }; +} + +/// An object that represents a latitude/longitude pair. +final class LatLng { + // ignore: public_member_api_docs + LatLng({required this.latitude, required this.longitude}); + + /// The latitude in degrees. It must be in the range [-90.0, +90.0]. + final double latitude; + + /// The longitude in degrees. It must be in the range [-180.0, +180.0]. + final double longitude; + + /// Convert to json object. + Map toJson() => { + 'latitude': latitude, + 'longitude': longitude, + }; +} + +/// The configuration that specifies information which can be used by tools +/// during inference calls. +final class RetrievalConfig { + // ignore: public_member_api_docs + RetrievalConfig({this.latLng, this.languageCode}); + + /// A latitude/longitude pair. + final LatLng? latLng; + + /// The language code. + final String? languageCode; + + /// Convert to json object. + Map toJson() => { + if (latLng case final latLng?) 'latLng': latLng.toJson(), + if (languageCode case final languageCode?) 'languageCode': languageCode, }; } diff --git a/packages/firebase_ai/firebase_ai/test/developer_api_test.dart b/packages/firebase_ai/firebase_ai/test/developer_api_test.dart index 40ce3739e1b9..85116e454646 100644 --- a/packages/firebase_ai/firebase_ai/test/developer_api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/developer_api_test.dart @@ -219,6 +219,43 @@ void main() { expect(groundingSupports.groundingChunkIndices, [0]); }); + test('parses json with google maps grounding chunk', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a maps response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'groundingChunks': [ + { + 'maps': { + 'uri': 'https://maps.google.com/?cid=123', + 'title': 'Google HQ', + 'placeId': 'ChIJS5dFe_cZzosR26ZvwqWaMAM', + } + } + ], + } + } + ] + }; + + final response = DeveloperSerialization() + .parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + final groundingChunk = groundingMetadata!.groundingChunks.first; + expect(groundingChunk.maps?.uri, 'https://maps.google.com/?cid=123'); + expect(groundingChunk.maps?.title, 'Google HQ'); + expect(groundingChunk.maps?.placeId, 'ChIJS5dFe_cZzosR26ZvwqWaMAM'); + expect(groundingChunk.web, isNull); + }); + test( 'parses groundingMetadata with all optional fields null/missing and empty lists', () { diff --git a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart index ace2fa4d673c..71c347f24333 100644 --- a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart +++ b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart @@ -96,6 +96,21 @@ void main() { expect(vertexAIAppCheck.useLimitedUseAppCheckTokens, true); }); - // ... other tests (e.g., with different parameters) + test('generativeModel creation with Grounding tools', () { + final ai = FirebaseAI.googleAI(); + + final model = ai.generativeModel( + model: 'gemini-2.5-flash', + tools: [Tool.googleMaps()], + toolConfig: ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.42, longitude: -122.08), + languageCode: 'en-US', + ), + ), + ); + + expect(model, isA()); + }); }); } 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..3078177f180b 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,43 @@ void main() { expect(urlContextMetadata.urlMetadata[0].urlRetrievalStatus, UrlRetrievalStatus.error); }); + + test('parses json with google maps grounding chunk', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a maps response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'groundingChunks': [ + { + 'maps': { + 'uri': 'https://maps.google.com/?cid=123', + 'title': 'Google HQ', + 'placeId': 'ChIJS5dFe_cZzosR26ZvwqWaMAM', + } + } + ], + } + } + ] + }; + + final response = + VertexSerialization().parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + final groundingChunk = groundingMetadata!.groundingChunks.first; + expect(groundingChunk.maps?.uri, 'https://maps.google.com/?cid=123'); + expect(groundingChunk.maps?.title, 'Google HQ'); + expect(groundingChunk.maps?.placeId, 'ChIJS5dFe_cZzosR26ZvwqWaMAM'); + expect(groundingChunk.web, isNull); + }); }); group('parses and throws error responses', () { diff --git a/packages/firebase_ai/firebase_ai/test/server_template_test.dart b/packages/firebase_ai/firebase_ai/test/server_template_test.dart index ebc2170e7037..71d0905a7424 100644 --- a/packages/firebase_ai/firebase_ai/test/server_template_test.dart +++ b/packages/firebase_ai/firebase_ai/test/server_template_test.dart @@ -86,6 +86,37 @@ void main() { expect(response.text, 'Some response'); }); + test('generateContent with TemplateToolConfig passes retrievalConfig', + () async { + final mockHttp = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + expect(request.url.path, + endsWith('/templates/$templateId:templateGenerateContent')); + expect(body['inputs'], {'prompt': 'Some prompt'}); + expect(body['toolConfig'], { + 'retrievalConfig': { + 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'languageCode': 'en' + } + }); + return http.Response(jsonEncode(_arbitraryGenerateContentResponse), 200, + headers: {'content-type': 'application/json'}); + }); + + final model = createModel(mockHttp); + final response = await model.generateContent( + templateId, + inputs: {'prompt': 'Some prompt'}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 1, longitude: 2), + languageCode: 'en', + ), + ), + ); + expect(response.text, 'Some response'); + }); + test('generateContentStream can make successful request', () async { final mockHttp = MockClient((request) async { final body = jsonDecode(request.body) as Map; @@ -105,6 +136,41 @@ void main() { final response = await responseStream.first; expect(response.text, 'Some response'); }); + + test('generateContentStream with TemplateToolConfig passes retrievalConfig', + () async { + final mockHttp = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + expect(request.url.path, + endsWith('/templates/$templateId:templateStreamGenerateContent')); + expect(body['inputs'], {'prompt': 'Some prompt'}); + expect(body['toolConfig'], { + 'retrievalConfig': { + 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'languageCode': 'en' + } + }); + final responsePayload = jsonEncode(_arbitraryGenerateContentResponse); + final stream = Stream.value(utf8.encode('data: $responsePayload')); + final streamedResponse = http.StreamedResponse(stream, 200, + headers: {'content-type': 'application/json'}); + return http.Response.fromStream(streamedResponse); + }); + + final model = createModel(mockHttp); + final responseStream = model.generateContentStream( + templateId, + inputs: {'prompt': 'Some prompt'}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 1, longitude: 2), + languageCode: 'en', + ), + ), + ); + final response = await responseStream.first; + expect(response.text, 'Some response'); + }); }); group('TemplateImagenModel', () { diff --git a/packages/firebase_ai/firebase_ai/test/tool_test.dart b/packages/firebase_ai/firebase_ai/test/tool_test.dart index 8ac727e3a963..ff5b06152612 100644 --- a/packages/firebase_ai/firebase_ai/test/tool_test.dart +++ b/packages/firebase_ai/firebase_ai/test/tool_test.dart @@ -314,6 +314,14 @@ void main() { }); }); + // Test Tool.googleMaps() + test('Tool.googleMaps()', () { + final tool = Tool.googleMaps(); + expect(tool.toJson(), { + 'googleMaps': {}, + }); + }); + // Test ToolConfig test('ToolConfig with FunctionCallingConfig', () { final config = ToolConfig( @@ -329,7 +337,50 @@ void main() { expect(config.toJson(), {}); }); - // Test GoogleSearch, CodeExecution, UrlContext toJson() + test('ToolConfig with RetrievalConfig', () { + final config = ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.422, longitude: -122.084), + languageCode: 'en-US', + ), + ); + expect(config.toJson(), { + 'retrievalConfig': { + 'latLng': {'latitude': 37.422, 'longitude': -122.084}, + 'languageCode': 'en-US', + }, + }); + }); + + // Test LatLng and RetrievalConfig + test('LatLng.toJson()', () { + final latLng = LatLng(latitude: 37.42, longitude: -122.08); + expect(latLng.toJson(), {'latitude': 37.42, 'longitude': -122.08}); + }); + + test('RetrievalConfig.toJson() with all fields', () { + final config = RetrievalConfig( + latLng: LatLng(latitude: 1.2, longitude: 2.1), + languageCode: 'fr', + ); + expect(config.toJson(), { + 'latLng': {'latitude': 1.2, 'longitude': 2.1}, + 'languageCode': 'fr', + }); + }); + + test('RetrievalConfig.toJson() with partial fields', () { + final config1 = + RetrievalConfig(latLng: LatLng(latitude: 1.2, longitude: 2.1)); + expect(config1.toJson(), { + 'latLng': {'latitude': 1.2, 'longitude': 2.1} + }); + + final config2 = RetrievalConfig(languageCode: 'fr'); + expect(config2.toJson(), {'languageCode': 'fr'}); + }); + + // Test GoogleSearch, CodeExecution, UrlContext, GoogleMaps toJson() test('GoogleSearch.toJson()', () { const search = GoogleSearch(); expect(search.toJson(), {}); @@ -344,5 +395,10 @@ void main() { const context = UrlContext(); expect(context.toJson(), {}); }); + + test('GoogleMaps.toJson()', () { + const maps = GoogleMaps(); + expect(maps.toJson(), {}); + }); }); }