diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart index e4cd355259dd..633106664ba1 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart @@ -42,8 +42,12 @@ class _FunctionCallingPageState extends State { late GenerativeModel _functionCallModel; late GenerativeModel _autoFunctionCallModel; late GenerativeModel _parallelAutoFunctionCallModel; + late GenerativeModel _complexSchemaModel; + late GenerativeModel _refDefJsonSchemaModel; late GenerativeModel _codeExecutionModel; late final AutoFunctionDeclaration _autoFetchWeatherTool; + late final AutoFunctionDeclaration _autoPlanVacationTool; + late final AutoFunctionDeclaration _autoProcessTransactionTool; final List _messages = []; bool _loading = false; bool _enableThinking = false; @@ -113,6 +117,85 @@ class _FunctionCallingPageState extends State { return getRestaurantMenu(restaurantName); }, ); + _autoPlanVacationTool = AutoFunctionDeclaration( + name: 'planVacation', + description: + 'Plans a complex vacation itinerary combining flights, hotels, and activities.', + parameters: { + 'destination': + Schema.string(description: 'The city or country to travel to.'), + 'travelers': Schema.integer( + description: 'Number of travelers.', + minimum: 1, + maximum: 10, + ), + 'travelClass': Schema.enumString( + enumValues: ['ECONOMY', 'BUSINESS', 'FIRST'], + description: 'The preferred travel class.', + ), + 'budget': + Schema.number(description: 'Total budget for the trip in USD.'), + 'activities': Schema.array( + items: Schema.string(), + description: 'A list of preferred activities.', + minItems: 1, + ), + 'accommodations': Schema.object( + description: 'Hotel preferences.', + properties: { + 'hotelType': Schema.string(), + 'stars': Schema.integer(minimum: 1, maximum: 5), + 'amenities': Schema.array(items: Schema.string()), + }, + optionalProperties: ['amenities'], + ), + }, + callable: (args) async { + return { + 'status': 'SUCCESS', + 'itineraryId': 'TRIP-98765', + 'destination': args['destination'], + 'estimatedCost': 3500.0, + 'message': 'Vacation planned successfully!', + }; + }, + ); + _autoProcessTransactionTool = AutoFunctionDeclaration( + name: 'processTransactions', + description: + 'Processes a list of financial transactions using a predefined transaction model reference.', + parameters: { + 'transactionsBlock': JSONSchema.object( + description: 'A block containing a list of transactions.', + properties: { + 'transactionsList': JSONSchema.array( + items: JSONSchema.ref( + r'#/properties/transactionsBlock/$defs/transactionDef', + ), + ), + }, + defs: { + 'transactionDef': JSONSchema.object( + properties: { + 'amount': JSONSchema.number(), + 'transactionId': JSONSchema.integer(), + 'currency': JSONSchema.string(), + }, + ), + }, + ), + }, + callable: (args) async { + final block = args['transactionsBlock'] as Map?; + final list = block?['transactionsList'] as List?; + return { + 'status': 'SUCCESS', + 'transactionsProcessed': list?.length ?? 0, + 'message': + 'Transactions processed successfully using the reference schema!', + }; + }, + ); _initializeModel(); } @@ -202,6 +285,20 @@ class _FunctionCallingPageState extends State { Tool.codeExecution(), ], ); + _complexSchemaModel = aiClient.generativeModel( + model: 'gemini-2.5-flash', + generationConfig: generationConfig, + tools: [ + Tool.functionDeclarations([_autoPlanVacationTool]), + ], + ); + _refDefJsonSchemaModel = aiClient.generativeModel( + model: 'gemini-2.5-flash', + generationConfig: generationConfig, + tools: [ + Tool.functionDeclarations([_autoProcessTransactionTool]), + ], + ); } // This is a hypothetical API to return a fake weather data collection for @@ -375,6 +472,28 @@ class _FunctionCallingPageState extends State { ), ], ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? _testComplexSchemaAutoFunctionCalling + : null, + child: const Text('Complex Schema Auto FC'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? _testRefDefJsonSchemaAutoFunctionCalling + : null, + child: const Text('Ref Def JSON Schema Auto FC'), + ), + ), + ], + ), ], ), ), @@ -608,6 +727,56 @@ class _FunctionCallingPageState extends State { }); } + Future _testComplexSchemaAutoFunctionCalling() async { + await _runTest(() async { + final chat = _complexSchemaModel.startChat(); + const prompt = + 'I want to plan a vacation to Paris for 2 people. We want to fly Business class, our budget is 5500 USD. We want to do wine tasting and museum tours. We prefer a 4-star boutique hotel with free breakfast.'; + + _messages.add(MessageData(text: prompt, fromUser: true)); + setState(() {}); + + final response = await chat.sendMessage(Content.text(prompt)); + + final thought = response.thoughtSummary; + if (thought != null) { + _messages + .add(MessageData(text: thought, fromUser: false, isThought: true)); + } + + if (response.text case final text?) { + _messages.add(MessageData(text: text)); + } else { + _messages.add(MessageData(text: 'No text response from model.')); + } + }); + } + + Future _testRefDefJsonSchemaAutoFunctionCalling() async { + await _runTest(() async { + final chat = _refDefJsonSchemaModel.startChat(); + const prompt = + r'Process two transactions. The first is $50.00 in USD (ID 98765) and the second is €30.00 in EUR (ID 98766).'; + + _messages.add(MessageData(text: prompt, fromUser: true)); + setState(() {}); + + final response = await chat.sendMessage(Content.text(prompt)); + + final thought = response.thoughtSummary; + if (thought != null) { + _messages + .add(MessageData(text: thought, fromUser: false, isThought: true)); + } + + if (response.text case final text?) { + _messages.add(MessageData(text: text)); + } else { + _messages.add(MessageData(text: 'No text response from model.')); + } + }); + } + void _showError(String message) { showDialog( context: context, diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 681989831e53..6f90f6a6d977 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -110,7 +110,7 @@ export 'src/live_api.dart' GoingAwayNotice, Transcription; export 'src/live_session.dart' show LiveSession; -export 'src/schema.dart' show Schema, SchemaType; +export 'src/schema.dart' show JSONSchema, Schema, SchemaType; export 'src/tool.dart' show diff --git a/packages/firebase_ai/firebase_ai/lib/src/schema.dart b/packages/firebase_ai/firebase_ai/lib/src/schema.dart index 2b0d5ce6a6bb..7ed783e47b3d 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/schema.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/schema.dart @@ -290,6 +290,251 @@ final class Schema { }; } +/// The definition of a JSON Schema data type. +/// +/// This class supports `$ref` and `$defs` for reusable sub-schemas. +final class JSONSchema extends Schema { + // ignore: public_member_api_docs + JSONSchema( + super.type, { + super.format, + super.description, + super.title, + super.nullable, + super.enumValues, + JSONSchema? items, + super.minItems, + super.maxItems, + super.minimum, + super.maximum, + Map? properties, + super.optionalProperties, + super.propertyOrdering, + List? anyOf, + this.ref, + this.defs, + }) : super( + items: items, + properties: properties, + anyOf: anyOf, + ); + + /// Construct a schema for an object with one or more properties. + JSONSchema.object({ + required Map properties, + List? optionalProperties, + List? propertyOrdering, + String? description, + String? title, + bool? nullable, + Map? defs, + }) : this( + SchemaType.object, + properties: properties, + optionalProperties: optionalProperties, + propertyOrdering: propertyOrdering, + description: description, + title: title, + nullable: nullable, + defs: defs, + ); + + /// Construct a schema for an array of values with a specified type. + JSONSchema.array({ + required JSONSchema items, + String? description, + String? title, + bool? nullable, + int? minItems, + int? maxItems, + }) : this( + SchemaType.array, + description: description, + title: title, + nullable: nullable, + items: items, + minItems: minItems, + maxItems: maxItems, + ); + + /// Construct a schema for bool value. + JSONSchema.boolean({ + String? description, + String? title, + bool? nullable, + }) : this( + SchemaType.boolean, + description: description, + title: title, + nullable: nullable, + ); + + /// Construct a schema for an integer number. + /// + /// Json schema integer doesn't support format. + JSONSchema.integer({ + String? description, + String? title, + bool? nullable, + int? minimum, + int? maximum, + }) : this( + SchemaType.integer, + description: description, + title: title, + nullable: nullable, + minimum: minimum?.toDouble(), + maximum: maximum?.toDouble(), + ); + + /// Construct a schema for a non-integer number. + /// + /// Json schema number doesn't support format. + JSONSchema.number({ + String? description, + String? title, + bool? nullable, + double? minimum, + double? maximum, + }) : this( + SchemaType.number, + description: description, + title: title, + nullable: nullable, + minimum: minimum, + maximum: maximum, + ); + + /// Construct a schema for String value with enumerated possible values. + JSONSchema.enumString({ + required List enumValues, + String? description, + String? title, + bool? nullable, + }) : this( + SchemaType.string, + enumValues: enumValues, + description: description, + title: title, + nullable: nullable, + format: 'enum', + ); + + /// Construct a schema for a String value. + JSONSchema.string({ + String? description, + String? title, + bool? nullable, + String? format, + }) : this( + SchemaType.string, + description: description, + title: title, + nullable: nullable, + format: format, + ); + + /// Construct a schema representing a value that must conform to + /// *any* (one or more) of the provided sub-schemas. + /// + /// This schema instructs the model to produce data that is valid against at + /// least one of the schemas listed in the `schemas` array. This is useful + /// when a field can accept multiple distinct types or structures. + /// + /// **Example:** A field that can hold either a simple user ID (integer) or a + /// detailed user object. + /// ``` + /// JSONSchema.anyOf(anyOf: [ + /// .JSONSchema.integer(description: "User ID"), + /// .JSONSchema.object(properties: [ + /// "userId": JSONSchema.integer(), + /// "userName": JSONSchema.string() + /// ], description: "Detailed User Object") + /// ]) + /// ``` + /// The generated data could be decoded based on which schema it matches. + JSONSchema.anyOf({ + required List schemas, + }) : this( + SchemaType.anyOf, // The type will be ignored in toJson + anyOf: schemas, + ); + + /// Construct a schema referencing another schema. + JSONSchema.ref(String ref) + : this( + SchemaType.ref, + ref: ref, + ); + + /// JSONSchema for the elements if this is a [SchemaType.array]. + @override + JSONSchema? get items => super.items as JSONSchema?; + + @override + set items(covariant JSONSchema? value) => super.items = value; + + /// Properties of this type if this is a [SchemaType.object]. + @override + Map? get properties => + super.properties as Map?; + + @override + set properties(covariant Map? value) => + super.properties = value; + + /// An array of [Schema] objects to validate generated content. + @override + List? get anyOf => super.anyOf as List?; + + @override + set anyOf(covariant List? value) => super.anyOf = value; + + /// Reference to another schema. + String? ref; + + /// JSONSchema definitions for creating reusable sub-schemas. + Map? defs; + + /// Convert to standard JSON JSONSchema object. + /// + /// Reference: https://ai.google.dev/api/caching#FunctionDeclaration + @override + Map toJson() => { + if (type != SchemaType.anyOf && type != SchemaType.ref) + 'type': nullable == true ? [type.name, 'null'] : type.name, + if (ref case final ref?) r'$ref': ref, + if (defs case final defs?) + r'$defs': { + for (final MapEntry(:key, :value) in defs.entries) + key: value.toJson() + }, + if (format case final format?) 'format': format, + if (description case final description?) 'description': description, + if (title case final title?) 'title': title, + if (enumValues case final enumValues?) 'enum': enumValues, + if (items case final items?) 'items': items.toJson(), + if (minItems case final minItems?) 'minItems': minItems, + if (maxItems case final maxItems?) 'maxItems': maxItems, + if (minimum case final minimum?) 'minimum': minimum, + if (maximum case final maximum?) 'maximum': maximum, + if (properties case final properties?) + 'properties': { + for (final MapEntry(:key, :value) in properties.entries) + key: value.toJson() + }, + // Calculate required properties based on optionalProperties + if (properties != null) + 'required': optionalProperties != null + ? properties!.keys + .where((key) => !optionalProperties!.contains(key)) + .toList() + : properties!.keys.toList(), + if (anyOf case final anyOf?) + 'anyOf': anyOf.map((e) => e.toJson()).toList(), + }; +} + /// The value type of a [Schema]. enum SchemaType { /// string type. @@ -310,6 +555,9 @@ enum SchemaType { /// object type object, + /// This schema is a reference type. + ref, + /// This schema is anyOf type. anyOf; @@ -321,6 +569,7 @@ enum SchemaType { boolean => 'BOOLEAN', array => 'ARRAY', object => 'OBJECT', + ref => 'null', anyOf => 'null', }; } diff --git a/packages/firebase_ai/firebase_ai/lib/src/tool.dart b/packages/firebase_ai/firebase_ai/lib/src/tool.dart index 6b92fbd5d96c..29a7c7124c53 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/tool.dart @@ -174,8 +174,12 @@ class FunctionDeclaration { FunctionDeclaration(this.name, this.description, {required Map parameters, List optionalParameters = const []}) - : _schemaObject = Schema.object( - properties: parameters, optionalProperties: optionalParameters); + : _schemaObject = parameters.values.any((s) => s is JSONSchema) + ? JSONSchema.object( + properties: parameters.cast(), + optionalProperties: optionalParameters) + : Schema.object( + properties: parameters, optionalProperties: optionalParameters); /// The name of the function. /// @@ -192,7 +196,10 @@ class FunctionDeclaration { Map toJson() => { 'name': name, 'description': description, - 'parameters': _schemaObject.toJson() + if (_schemaObject is JSONSchema) + 'parametersJsonSchema': _schemaObject.toJson() + else + 'parameters': _schemaObject.toJson(), }; } diff --git a/packages/firebase_ai/firebase_ai/test/schema_test.dart b/packages/firebase_ai/firebase_ai/test/schema_test.dart index e4b47be4be94..724c803d3080 100644 --- a/packages/firebase_ai/firebase_ai/test/schema_test.dart +++ b/packages/firebase_ai/firebase_ai/test/schema_test.dart @@ -147,6 +147,40 @@ void main() { }); }); + test('JSONSchema.object with defs', () { + final properties = { + 'metadata': JSONSchema.ref('#/metadata_schema'), + }; + final defs = { + 'metadata_schema': JSONSchema.object(properties: { + 'id': JSONSchema.string(), + }) + }; + final schema = JSONSchema.object( + properties: properties, + defs: defs, + ); + expect(schema.type, SchemaType.object); + expect(schema.properties, properties); + expect(schema.defs, defs); + expect(schema.toJson(), { + 'type': 'object', + 'properties': { + 'metadata': {r'$ref': '#/metadata_schema'}, + }, + 'required': ['metadata'], + r'$defs': { + 'metadata_schema': { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + }, + 'required': ['id'], + } + } + }); + }); + test('Schema.object with empty optionalProperties', () { final properties = { 'name': Schema.string(), @@ -253,10 +287,33 @@ void main() { expect(SchemaType.boolean.toJson(), 'BOOLEAN'); expect(SchemaType.array.toJson(), 'ARRAY'); expect(SchemaType.object.toJson(), 'OBJECT'); + expect(SchemaType.ref.toJson(), 'null'); expect(SchemaType.anyOf.toJson(), 'null'); // As per implementation, 'null' string for anyOf }); + // Test JSONSchema.ref + test('JSONSchema.ref', () { + final schema = JSONSchema.ref('#/components/schemas/User'); + expect(schema.type, SchemaType.ref); + expect(schema.ref, '#/components/schemas/User'); + expect(schema.toJson(), { + r'$ref': '#/components/schemas/User', + }); + }); + + test('JSONSchema.toJson handles nullable correctly', () { + final schema = JSONSchema.integer(nullable: true); + expect(schema.toJson(), { + 'type': ['integer', 'null'], + }); + + final stringSchema = JSONSchema.string(nullable: false); + expect(stringSchema.toJson(), { + 'type': 'string', + }); + }); + // Test edge cases test('Schema.object with no properties', () { final schema = Schema.object(properties: {}); @@ -293,5 +350,26 @@ void main() { expect(schema.anyOf, isNull); expect(schema.toJson(), {'type': 'STRING'}); }); + + test('JSONSchema with recursive array referencing another schema', () { + final schema = JSONSchema.array( + items: JSONSchema.ref('#/components/schemas/Item'), + nullable: true, + ); + expect(schema.toJson(), { + 'type': ['array', 'null'], + 'items': {r'$ref': '#/components/schemas/Item'}, + }); + }); + + test('JSONSchema manually constructed without matching values (ref)', () { + final schema = JSONSchema(SchemaType.ref); + expect(schema.toJson(), {}); // type is ignored, ref is null + }); + + test('Schema manually constructed without matching values (anyOf)', () { + final schema = Schema(SchemaType.anyOf); + expect(schema.toJson(), {}); // type is ignored, anyOf is null + }); }); } diff --git a/packages/firebase_ai/firebase_ai/test/tool_test.dart b/packages/firebase_ai/firebase_ai/test/tool_test.dart index affd00691b59..8ac727e3a963 100644 --- a/packages/firebase_ai/firebase_ai/test/tool_test.dart +++ b/packages/firebase_ai/firebase_ai/test/tool_test.dart @@ -113,6 +113,120 @@ void main() { expect(resultWithName, {'message': 'Hello, Bob!'}); }); + test('AutoFunctionDeclaration with JSONSchema', () async { + final parametersSchema = { + 'count': JSONSchema.integer(), + }; + + final autoDeclaration = AutoFunctionDeclaration( + name: 'testSchema', + description: 'Tests JSON Schema output.', + parameters: parametersSchema, + callable: (args) async => {'result': args['count']}, + ); + + expect(autoDeclaration.toJson(), { + 'name': 'testSchema', + 'description': 'Tests JSON Schema output.', + 'parametersJsonSchema': { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer'}, + }, + 'required': ['count'], + }, + }); + }); + + test('FunctionDeclaration with JSONSchema', () { + final parametersSchema = { + 'count': JSONSchema.integer(), + }; + + final declaration = FunctionDeclaration( + 'testSchema', + 'Tests JSON Schema output.', + parameters: parametersSchema, + ); + + expect(declaration.toJson(), { + 'name': 'testSchema', + 'description': 'Tests JSON Schema output.', + 'parametersJsonSchema': { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer'}, + }, + 'required': ['count'], + }, + }); + }); + + test( + 'FunctionDeclaration mixing Schema and JSONSchema throws TypeError on toJson', + () { + final mixedParametersSchema = { + 'count': Schema.integer(), + 'mixed': JSONSchema.string(), + }; + + final declaration = FunctionDeclaration( + 'testMixedSchema', + 'Tests mixed schemas.', + parameters: mixedParametersSchema, + ); + + expect(declaration.toJson, throwsA(isA())); + }); + + test('FunctionDeclaration with JSONSchema defs and ref', () { + final parametersSchema = { + 'metadataContainer': JSONSchema.object( + properties: { + 'metadata': JSONSchema.ref('#/metadata_schema'), + }, + defs: { + 'metadata_schema': JSONSchema.object(properties: { + 'id': JSONSchema.string(), + }), + }, + ), + }; + + final declaration = FunctionDeclaration( + 'testDefsRef', + 'Tests defs and ref.', + parameters: parametersSchema, + ); + + expect(declaration.toJson(), { + 'name': 'testDefsRef', + 'description': 'Tests defs and ref.', + 'parametersJsonSchema': { + 'type': 'object', + 'properties': { + 'metadataContainer': { + 'type': 'object', + 'properties': { + 'metadata': {r'$ref': '#/metadata_schema'}, + }, + 'required': ['metadata'], + r'$defs': { + 'metadata_schema': { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + }, + 'required': ['id'], + } + } + } + }, + 'required': ['metadataContainer'], + }, + }); + }); + // Test FunctionCallingConfig test('FunctionCallingConfig.auto()', () { final config = FunctionCallingConfig.auto();