diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index 1fb2886ed14..ee4d10107ee 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -68,44 +68,77 @@ public final class Schema: Sendable { /// The format of the data. public let format: String? - /// A brief description of the parameter. + /// A human-readable explanation of the purpose of the schema or property. While not strictly + /// enforced on the value itself, good descriptions significantly help the model understand the + /// context and generate more relevant and accurate output. public let description: String? + /// 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. + public let title: String? + /// Indicates if the value may be null. public let nullable: Bool? /// Possible values of the element of type "STRING" with "enum" format. public let enumValues: [String]? - /// Schema of the elements of type `"ARRAY"`. + /// Defines the schema for the elements within the `"ARRAY"`. All items in the generated array + /// must conform to this schema definition. This can be a simple type (like .string) or a complex + /// nested object schema. public let items: Schema? - /// The minimum number of items (elements) in a schema of type `"ARRAY"`. + /// An integer specifying the minimum number of items the generated `"ARRAY"` must contain. public let minItems: Int? - /// The maximum number of items (elements) in a schema of type `"ARRAY"`. + /// An integer specifying the maximum number of items the generated `"ARRAY"` must contain. public let maxItems: Int? - /// Properties of type `"OBJECT"`. + /// The minimum value of a numeric type. + public let minimum: Double? + + /// The maximum value of a numeric type. + public let maximum: Double? + + /// Defines the members (key-value pairs) expected within an object. It's a dictionary where keys + /// are the property names (strings) and values are nested `Schema` definitions describing each + /// property's type and constraints. public let properties: [String: Schema]? - /// Required properties of type `"OBJECT"`. + /// An array of strings, where each string is the name of a property defined in the `properties` + /// dictionary that must be present in the generated object. If a property is listed here, the + /// model must include it in the output. public let requiredProperties: [String]? + /// 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, subsequent parsing into native Swift objects (like Dictionaries or + /// Structs) might not preserve this order. This parameter primarily affects the raw JSON string + /// serialization. + public let propertyOrdering: [String]? + required init(type: DataType, format: String? = nil, description: String? = nil, + title: String? = nil, nullable: Bool = false, enumValues: [String]? = nil, items: Schema? = nil, - minItems: Int? = nil, maxItems: Int? = nil, - properties: [String: Schema]? = nil, requiredProperties: [String]? = nil) { + minItems: Int? = nil, maxItems: Int? = nil, minimum: Double? = nil, + maximum: Double? = nil, properties: [String: Schema]? = nil, + requiredProperties: [String]? = nil, propertyOrdering: [String]? = nil) { dataType = type self.format = format self.description = description + self.title = title self.nullable = nullable self.enumValues = enumValues self.items = items self.minItems = minItems self.maxItems = maxItems + self.minimum = minimum + self.maximum = maximum self.properties = properties self.requiredProperties = requiredProperties + self.propertyOrdering = propertyOrdering } /// Returns a `Schema` representing a string value. @@ -184,12 +217,19 @@ public final class Schema: Sendable { /// use Markdown format. /// - nullable: If `true`, instructs the model that it may generate `null` instead of a number; /// defaults to `false`, enforcing that a number is generated. - public static func float(description: String? = nil, nullable: Bool = false) -> Schema { + /// - minimum: If specified, instructs the model that the value should be greater than or + /// equal to the specified minimum. + /// - maximum: If specified, instructs the model that the value should be less than or equal + /// to the specified maximum. + public static func float(description: String? = nil, nullable: Bool = false, + minimum: Float? = nil, maximum: Float? = nil) -> Schema { return self.init( type: .number, format: "float", description: description, - nullable: nullable + nullable: nullable, + minimum: minimum.map { Double($0) }, + maximum: maximum.map { Double($0) } ) } @@ -203,11 +243,18 @@ public final class Schema: Sendable { /// use Markdown format. /// - nullable: If `true`, instructs the model that it may return `null` instead of a number; /// defaults to `false`, enforcing that a number is returned. - public static func double(description: String? = nil, nullable: Bool = false) -> Schema { + /// - minimum: If specified, instructs the model that the value should be greater than or + /// equal to the specified minimum. + /// - maximum: If specified, instructs the model that the value should be less than or equal + /// to the specified maximum. + public static func double(description: String? = nil, nullable: Bool = false, + minimum: Double? = nil, maximum: Double? = nil) -> Schema { return self.init( type: .number, description: description, - nullable: nullable + nullable: nullable, + minimum: minimum, + maximum: maximum ) } @@ -232,12 +279,15 @@ public final class Schema: Sendable { /// formats ``IntegerFormat/int32`` and ``IntegerFormat/int64`` are supported; custom values /// may be specified using ``IntegerFormat/custom(_:)`` but may be ignored by the model. public static func integer(description: String? = nil, nullable: Bool = false, - format: IntegerFormat? = nil) -> Schema { + format: IntegerFormat? = nil, + minimum: Int? = nil, maximum: Int? = nil) -> Schema { return self.init( type: .integer, format: format?.rawValue, description: description, - nullable: nullable + nullable: nullable.self, + minimum: minimum.map { Double($0) }, + maximum: maximum.map { Double($0) } ) } @@ -317,7 +367,9 @@ public final class Schema: Sendable { /// - nullable: If `true`, instructs the model that it may return `null` instead of an object; /// defaults to `false`, enforcing that an object is returned. public static func object(properties: [String: Schema], optionalProperties: [String] = [], - description: String? = nil, nullable: Bool = false) -> Schema { + propertyOrdering: [String]? = nil, + description: String? = nil, title: String? = nil, + nullable: Bool = false) -> Schema { var requiredProperties = Set(properties.keys) for optionalProperty in optionalProperties { guard properties.keys.contains(optionalProperty) else { @@ -329,9 +381,11 @@ public final class Schema: Sendable { return self.init( type: .object, description: description, + title: title, nullable: nullable, properties: properties, - requiredProperties: requiredProperties.sorted() + requiredProperties: requiredProperties.sorted(), + propertyOrdering: propertyOrdering ) } } @@ -344,12 +398,16 @@ extension Schema: Encodable { case dataType = "type" case format case description + case title case nullable case enumValues = "enum" case items case minItems case maxItems + case minimum + case maximum case properties case requiredProperties = "required" + case propertyOrdering } } diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 35eb6442d12..923ee1a238e 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -55,12 +55,12 @@ struct SchemaTests { generationConfig: GenerationConfig( responseMIMEType: "application/json", responseSchema: - .array( - items: .string(description: "The name of the city"), - description: "A list of city names", - minItems: 3, - maxItems: 5 - ) + .array( + items: .string(description: "The name of the city"), + description: "A list of city names", + minItems: 3, + maxItems: 5 + ) ), safetySettings: safetySettings ) @@ -72,4 +72,80 @@ struct SchemaTests { #expect(decodedJSON.count >= 3, "Expected at least 3 cities, but got \(decodedJSON.count)") #expect(decodedJSON.count <= 5, "Expected at most 5 cities, but got \(decodedJSON.count)") } + + @Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1) + func generateContentSchemaNumberRange(_ config: InstanceConfig) async throws { + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + generationConfig: GenerationConfig( + responseMIMEType: "application/json", + responseSchema: .integer( + description: "A number", + minimum: 110, + maximum: 120 + ) + ), + safetySettings: safetySettings + ) + let prompt = "Give me a number" + let response = try await model.generateContent(prompt) + let text = try #require(response.text).trimmingCharacters(in: .whitespacesAndNewlines) + let jsonData = try #require(text.data(using: .utf8)) + let decodedNumber = try JSONDecoder().decode(Double.self, from: jsonData) + #expect(decodedNumber >= 110.0, "Expected a number >= 110, but got \(decodedNumber)") + #expect(decodedNumber <= 120.0, "Expected a number <= 120, but got \(decodedNumber)") + } + + @Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1) + func generateContentSchemaNumberRangeMultiType(_ config: InstanceConfig) async throws { + struct ProductInfo: Codable { + let productName: String + let rating: Int // Will correspond to .integer in schema + let price: Double // Will correspond to .double in schema + let salePrice: Float // Will correspond to .float in schema + } + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + generationConfig: GenerationConfig( + responseMIMEType: "application/json", + responseSchema: .object( + properties: [ + "productName": .string(description: "The name of the product"), + "price": .double( + description: "A price", + minimum: 10.00, + maximum: 120.00 + ), + "salePrice": .float( + description: "A sale price", + minimum: 5.00, + maximum: 90.00 + ), + "rating": .integer( + description: "A rating", + minimum: 1, + maximum: 5 + ), + ], + propertyOrdering: ["salePrice", "rating", "price", "productName"], + title: "ProductInfo" + ), + ), + safetySettings: safetySettings + ) + let prompt = "Describe a premium wireless headphone, including a user rating and price." + let response = try await model.generateContent(prompt) + let text = try #require(response.text).trimmingCharacters(in: .whitespacesAndNewlines) + let jsonData = try #require(text.data(using: .utf8)) + let decodedProduct = try JSONDecoder().decode(ProductInfo.self, from: jsonData) + let price = decodedProduct.price + let salePrice = decodedProduct.salePrice + let rating = decodedProduct.rating + #expect(price >= 10.0, "Expected a price >= 10.00, but got \(price)") + #expect(price <= 120.0, "Expected a price <= 120.00, but got \(price)") + #expect(salePrice >= 5.0, "Expected a salePrice >= 5.00, but got \(salePrice)") + #expect(salePrice <= 90.0, "Expected a salePrice <= 90.00, but got \(salePrice)") + #expect(rating >= 1, "Expected a rating >= 1, but got \(rating)") + #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") + } }