diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart b/packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart index e73f44b355f3..44dc89e75a8b 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart +++ b/packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart @@ -23,24 +23,35 @@ final class Schema { this.type, { this.format, this.description, + this.title, this.nullable, this.enumValues, this.items, + this.minItems, + this.maxItems, + this.minimum, + this.maximum, this.properties, this.optionalProperties, + this.propertyOrdering, + this.anyOf, }); /// Construct a schema for an object with one or more properties. Schema.object({ required Map properties, List? optionalProperties, + List? propertyOrdering, String? description, + String? title, bool? nullable, }) : this( SchemaType.object, properties: properties, optionalProperties: optionalProperties, + propertyOrdering: propertyOrdering, description: description, + title: title, nullable: nullable, ); @@ -48,21 +59,29 @@ final class Schema { Schema.array({ required Schema 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. Schema.boolean({ String? description, + String? title, bool? nullable, }) : this( SchemaType.boolean, description: description, + title: title, nullable: nullable, ); @@ -71,13 +90,19 @@ final class Schema { /// The [format] may be "int32" or "int64". Schema.integer({ String? description, + String? title, bool? nullable, String? format, + int? minimum, + int? maximum, }) : this( SchemaType.integer, description: description, + title: title, nullable: nullable, format: format, + minimum: minimum?.toDouble(), + maximum: maximum?.toDouble(), ); /// Construct a schema for a non-integer number. @@ -85,24 +110,32 @@ final class Schema { /// The [format] may be "float" or "double". Schema.number({ String? description, + String? title, bool? nullable, String? format, + double? minimum, + double? maximum, }) : this( SchemaType.number, description: description, + title: title, nullable: nullable, format: format, + minimum: minimum, + maximum: maximum, ); /// Construct a schema for String value with enumerated possible values. Schema.enumString({ required List enumValues, String? description, + String? title, bool? nullable, }) : this( SchemaType.string, enumValues: enumValues, description: description, + title: title, nullable: nullable, format: 'enum', ); @@ -110,10 +143,42 @@ final class Schema { /// Construct a schema for a String value. Schema.string({ String? description, + String? title, bool? nullable, String? format, - }) : this(SchemaType.string, - description: description, nullable: nullable, format: 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. + /// ``` + /// Schema.anyOf(anyOf: [ + /// .Schema.integer(description: "User ID"), + /// .Schema.object(properties: [ + /// "userId": Schema.integer(), + /// "userName": Schema.string() + /// ], description: "Detailed User Object") + /// ]) + /// ``` + /// The generated data could be decoded based on which schema it matches. + Schema.anyOf({ + required List schemas, + }) : this( + SchemaType.anyOf, // The type will be ignored in toJson + anyOf: schemas, + ); /// The type of this value. SchemaType type; @@ -134,6 +199,13 @@ final class Schema { /// Parameter description may be formatted as Markdown. String? description; + /// A human-readable name/summary for the schema or a specific property. + /// + /// This helps document the schema's purpose but doesn't typically constrain + /// the generated value. It can subtly guide the model by clarifying the + /// intent of a field. + String? title; + /// Whether the value mey be null. bool? nullable; @@ -143,6 +215,18 @@ final class Schema { /// Schema for the elements if this is a [SchemaType.array]. Schema? items; + /// An integer specifying the minimum number of items [SchemaType.array] must contain. + int? minItems; + + /// An integer specifying the maximum number of items [SchemaType.array] must contain. + int? maxItems; + + /// The minimum value of a numeric type. + double? minimum; + + /// The maximum value of a numeric type. + double? maximum; + /// Properties of this type if this is a [SchemaType.object]. Map? properties; @@ -153,14 +237,40 @@ final class Schema { /// treated as required properties List? optionalProperties; + /// Suggesting order of the properties. + /// + /// A specific hint provided to the Gemini model, suggesting the order in + /// which the keys should appear in the generated JSON string. + /// Important: Standard JSON objects are inherently unordered collections of + /// key-value pairs. While the model will try to respect PropertyOrdering in + /// its textual JSON output. + List? propertyOrdering; + + /// An array of [Schema] objects to validate generated content. + /// + /// The generated data must be valid against *any* (one or more) + /// of the schemas listed in this array. This allows specifying multiple + /// possible structures or types for a single field. + /// + /// For example, a value could be either a `String` or an `Int`: + /// ``` + /// Schema.anyOf(schemas: [Schema.string(), Schema.integer()]); + List? anyOf; + /// Convert to json object. Map toJson() => { - 'type': type.toJson(), + if (type != SchemaType.anyOf) + 'type': type.toJson(), // Omit the field while type is anyOf if (format case final format?) 'format': format, if (description case final description?) 'description': description, + if (title case final title?) 'title': title, if (nullable case final nullable?) 'nullable': nullable, 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) @@ -173,6 +283,10 @@ final class Schema { .where((key) => !optionalProperties!.contains(key)) .toList() : properties!.keys.toList(), + if (propertyOrdering case final propertyOrdering?) + 'propertyOrdering': propertyOrdering, + if (anyOf case final anyOf?) + 'anyOf': anyOf.map((e) => e.toJson()).toList(), }; } @@ -194,7 +308,10 @@ enum SchemaType { array, /// object type - object; + object, + + /// This schema is anyOf type. + anyOf; /// Convert to json object. String toJson() => switch (this) { @@ -204,5 +321,6 @@ enum SchemaType { boolean => 'BOOLEAN', array => 'ARRAY', object => 'OBJECT', + anyOf => 'null', }; } diff --git a/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart index cf66017c87d1..c6de40ee0710 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart @@ -19,67 +19,98 @@ void main() { group('Schema Tests', () { // Test basic constructors and toJson() for primitive types test('Schema.boolean', () { - final schema = - Schema.boolean(description: 'A boolean value', nullable: true); + final schema = Schema.boolean( + description: 'A boolean value', nullable: true, title: 'Is Active'); expect(schema.type, SchemaType.boolean); expect(schema.description, 'A boolean value'); expect(schema.nullable, true); + expect(schema.title, 'Is Active'); expect(schema.toJson(), { 'type': 'BOOLEAN', 'description': 'A boolean value', 'nullable': true, + 'title': 'Is Active', }); }); test('Schema.integer', () { - final schema = Schema.integer(format: 'int32'); + final schema = Schema.integer( + format: 'int32', minimum: 0, maximum: 100, title: 'Count'); expect(schema.type, SchemaType.integer); expect(schema.format, 'int32'); + expect(schema.minimum, 0); + expect(schema.maximum, 100); + expect(schema.title, 'Count'); expect(schema.toJson(), { 'type': 'INTEGER', 'format': 'int32', + 'minimum': 0.0, // Ensure double conversion + 'maximum': 100.0, // Ensure double conversion + 'title': 'Count', }); }); test('Schema.number', () { - final schema = Schema.number(format: 'double', nullable: false); + final schema = Schema.number( + format: 'double', + nullable: false, + minimum: 0.5, + maximum: 99.5, + title: 'Percentage'); expect(schema.type, SchemaType.number); expect(schema.format, 'double'); expect(schema.nullable, false); + expect(schema.minimum, 0.5); + expect(schema.maximum, 99.5); + expect(schema.title, 'Percentage'); expect(schema.toJson(), { 'type': 'NUMBER', 'format': 'double', 'nullable': false, + 'minimum': 0.5, + 'maximum': 99.5, + 'title': 'Percentage', }); }); test('Schema.string', () { - final schema = Schema.string(); + final schema = Schema.string(title: 'User Name'); expect(schema.type, SchemaType.string); - expect(schema.toJson(), {'type': 'STRING'}); + expect(schema.title, 'User Name'); + expect(schema.toJson(), {'type': 'STRING', 'title': 'User Name'}); }); test('Schema.enumString', () { - final schema = Schema.enumString(enumValues: ['value1', 'value2']); + final schema = + Schema.enumString(enumValues: ['value1', 'value2'], title: 'Status'); expect(schema.type, SchemaType.string); expect(schema.format, 'enum'); expect(schema.enumValues, ['value1', 'value2']); + expect(schema.title, 'Status'); expect(schema.toJson(), { 'type': 'STRING', 'format': 'enum', 'enum': ['value1', 'value2'], + 'title': 'Status', }); }); // Test constructors and toJson() for complex types test('Schema.array', () { final itemSchema = Schema.string(); - final schema = Schema.array(items: itemSchema); + final schema = Schema.array( + items: itemSchema, minItems: 1, maxItems: 5, title: 'Tags'); expect(schema.type, SchemaType.array); expect(schema.items, itemSchema); + expect(schema.minItems, 1); + expect(schema.maxItems, 5); + expect(schema.title, 'Tags'); expect(schema.toJson(), { 'type': 'ARRAY', 'items': {'type': 'STRING'}, + 'minItems': 1, + 'maxItems': 5, + 'title': 'Tags', }); }); @@ -87,21 +118,32 @@ void main() { final properties = { 'name': Schema.string(), 'age': Schema.integer(), + 'city': Schema.string(description: 'City of residence'), }; final schema = Schema.object( properties: properties, optionalProperties: ['age'], + propertyOrdering: ['name', 'city', 'age'], + title: 'User Profile', + description: 'Represents a user profile', ); expect(schema.type, SchemaType.object); expect(schema.properties, properties); expect(schema.optionalProperties, ['age']); + expect(schema.propertyOrdering, ['name', 'city', 'age']); + expect(schema.title, 'User Profile'); + expect(schema.description, 'Represents a user profile'); expect(schema.toJson(), { 'type': 'OBJECT', 'properties': { 'name': {'type': 'STRING'}, 'age': {'type': 'INTEGER'}, + 'city': {'type': 'STRING', 'description': 'City of residence'}, }, - 'required': ['name'], + 'required': ['name', 'city'], + 'propertyOrdering': ['name', 'city', 'age'], + 'title': 'User Profile', + 'description': 'Represents a user profile', }); }); @@ -112,16 +154,94 @@ void main() { }; final schema = Schema.object( properties: properties, + // No optionalProperties, so all are required + ); + expect(schema.type, SchemaType.object); + expect(schema.properties, properties); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + }, + 'required': ['name', 'age'], // All keys from properties + }); + }); + + test('Schema.object with all properties optional', () { + final properties = { + 'name': Schema.string(), + 'age': Schema.integer(), + }; + final schema = Schema.object( + properties: properties, + optionalProperties: ['name', 'age'], ); expect(schema.type, SchemaType.object); expect(schema.properties, properties); + expect(schema.optionalProperties, ['name', 'age']); expect(schema.toJson(), { 'type': 'OBJECT', 'properties': { 'name': {'type': 'STRING'}, 'age': {'type': 'INTEGER'}, }, - 'required': ['name', 'age'], + 'required': [], // Empty list as all are optional + }); + }); + + // Test Schema.anyOf + test('Schema.anyOf', () { + final schema1 = Schema.string(description: 'A string value'); + final schema2 = Schema.integer(description: 'An integer value'); + final schema = Schema.anyOf(schemas: [schema1, schema2]); + + // The type field is SchemaType.anyOf internally for dispatching toJson + // but it should not be present in the final JSON for `anyOf`. + expect(schema.type, SchemaType.anyOf); + expect(schema.anyOf, [schema1, schema2]); + expect(schema.toJson(), { + 'anyOf': [ + {'type': 'STRING', 'description': 'A string value'}, + {'type': 'INTEGER', 'description': 'An integer value'}, + ], + }); + }); + + test('Schema.anyOf with complex types', () { + final userSchema = Schema.object(properties: { + 'id': Schema.integer(), + 'username': Schema.string(), + }, optionalProperties: [ + 'username' + ]); + final errorSchema = Schema.object(properties: { + 'errorCode': Schema.integer(), + 'errorMessage': Schema.string(), + }); + final schema = Schema.anyOf(schemas: [userSchema, errorSchema]); + + expect(schema.type, SchemaType.anyOf); + expect(schema.anyOf?.length, 2); + expect(schema.toJson(), { + 'anyOf': [ + { + 'type': 'OBJECT', + 'properties': { + 'id': {'type': 'INTEGER'}, + 'username': {'type': 'STRING'}, + }, + 'required': ['id'], + }, + { + 'type': 'OBJECT', + 'properties': { + 'errorCode': {'type': 'INTEGER'}, + 'errorMessage': {'type': 'STRING'}, + }, + 'required': ['errorCode', 'errorMessage'], + }, + ], }); }); @@ -133,8 +253,45 @@ void main() { expect(SchemaType.boolean.toJson(), 'BOOLEAN'); expect(SchemaType.array.toJson(), 'ARRAY'); expect(SchemaType.object.toJson(), 'OBJECT'); + expect(SchemaType.anyOf.toJson(), + 'null'); // As per implementation, 'null' string for anyOf }); - // Add more tests as needed to cover other scenarios and edge cases + // Test edge cases + test('Schema.object with no properties', () { + final schema = Schema.object(properties: {}); + expect(schema.type, SchemaType.object); + expect(schema.properties, {}); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': {}, + 'required': [], + }); + }); + + test('Schema.array with no items (should not happen with constructor)', () { + // This is more of a theoretical test as the constructor requires `items`. + // We construct it manually to test `toJson` robustness. + final schema = Schema(SchemaType.array); + expect(schema.type, SchemaType.array); + expect(schema.toJson(), { + 'type': 'ARRAY', + // 'items' field should be absent if items is null + }); + }); + + test('Schema with all optional fields null', () { + final schema = Schema(SchemaType.string); // Only type is provided + expect(schema.type, SchemaType.string); + expect(schema.format, isNull); + expect(schema.description, isNull); + expect(schema.nullable, isNull); + expect(schema.enumValues, isNull); + expect(schema.items, isNull); + expect(schema.properties, isNull); + expect(schema.optionalProperties, isNull); + expect(schema.anyOf, isNull); + expect(schema.toJson(), {'type': 'STRING'}); + }); }); }