diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index 936109166d0..beb2ef14e32 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 11.12.0 - [added] **Public Preview**: Added support for specifying response modalities in `GenerationConfig`. This includes **public experimental** support for image generation using Gemini 2.0 Flash (`gemini-2.0-flash-exp`). (#14658) @@ -6,8 +6,9 @@ Note: This feature is in Public Preview and relies on experimental models, which means that it is not subject to any SLA or deprecation policy and could change in backwards-incompatible ways. -- [added] Added support for specifying the minimum and maximum number of items - (`minItems` / `maxItems`) to generate in an array `Schema`. (#14671) +- [added] Added support for more `Schema` fields: `minItems`/`maxItems` (array + size limits), `title` (schema name), `minimum`/`maximum` (numeric ranges), + `anyOf` (select from sub-schemas), and `propertyOrdering` (JSON key order). (#14647) - [fixed] Fixed an issue where network requests would fail in the iOS 18.4 simulator due to a `URLSession` bug introduced in Xcode 16.3. (#14677) diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index ee4d10107ee..7ad13667cfe 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -60,10 +60,11 @@ public final class Schema: Sendable { let rawValue: String } - let dataType: DataType + // May only be nil for `anyOf` schemas, which do not have an explicit `type` in the OpenAPI spec. + let dataType: DataType? /// The data type. - public var type: String { dataType.rawValue } + public var type: String { dataType?.rawValue ?? "UNSPECIFIED" } /// The format of the data. public let format: String? @@ -106,6 +107,16 @@ public final class Schema: Sendable { /// property's type and constraints. public let properties: [String: Schema]? + /// An array of `Schema` objects. 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 `Integer`: + /// ``` + /// Schema.anyOf(schemas: [.string(), .integer()]) + /// ``` + public let anyOf: [Schema]? + /// 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. @@ -119,12 +130,14 @@ public final class Schema: Sendable { /// 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, minimum: Double? = nil, - maximum: Double? = nil, properties: [String: Schema]? = nil, - requiredProperties: [String]? = nil, propertyOrdering: [String]? = nil) { + required init(type: DataType?, format: String? = nil, description: String? = nil, + title: String? = nil, nullable: Bool? = nil, enumValues: [String]? = nil, + items: Schema? = nil, minItems: Int? = nil, maxItems: Int? = nil, + minimum: Double? = nil, maximum: Double? = nil, anyOf: [Schema]? = nil, + properties: [String: Schema]? = nil, requiredProperties: [String]? = nil, + propertyOrdering: [String]? = nil) { + precondition(type != nil || anyOf != nil, + "A schema must have either a `type` or an `anyOf` array of sub-schemas.") dataType = type self.format = format self.description = description @@ -136,6 +149,7 @@ public final class Schema: Sendable { self.maxItems = maxItems self.minimum = minimum self.maximum = maximum + self.anyOf = anyOf self.properties = properties self.requiredProperties = requiredProperties self.propertyOrdering = propertyOrdering @@ -278,6 +292,10 @@ public final class Schema: Sendable { /// - format: An optional modifier describing the expected format of the integer. Currently the /// formats ``IntegerFormat/int32`` and ``IntegerFormat/int64`` are supported; custom values /// may be specified using ``IntegerFormat/custom(_:)`` but may be ignored by the model. + /// - 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 integer(description: String? = nil, nullable: Bool = false, format: IntegerFormat? = nil, minimum: Int? = nil, maximum: Int? = nil) -> Schema { @@ -362,8 +380,11 @@ public final class Schema: Sendable { /// - optionalProperties: A list of property names that may be be omitted in objects generated /// by the model; these names must correspond to the keys provided in the `properties` /// dictionary and may be an empty list. + /// - propertyOrdering: An optional hint to the model suggesting the order for keys in the + /// generated JSON string. See ``propertyOrdering`` for details. /// - description: An optional description of what the object should contain or represent; may /// use Markdown format. + /// - title: An optional human-readable name/summary for the object schema. /// - 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] = [], @@ -388,6 +409,38 @@ public final class Schema: Sendable { propertyOrdering: propertyOrdering ) } + + /// Returns 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(schemas: [ + /// .integer(description: "User ID"), + /// .object(properties: [ + /// "userId": .integer(), + /// "userName": .string() + /// ], description: "Detailed User Object") + /// ]) + /// ``` + /// The generated data could be decoded based on which schema it matches. + /// + /// - Parameters: + /// - schemas: An array of `Schema` objects. The generated data must be valid against at least + /// one of these schemas. The array must not be empty. + public static func anyOf(schemas: [Schema]) -> Schema { + if schemas.isEmpty { + VertexLog.error(code: .invalidSchemaFormat, "The `anyOf` schemas array cannot be empty.") + } + // Note: The 'type' for an 'anyOf' schema is implicitly defined by the presence of the + // 'anyOf' keyword and doesn't have a specific explicit type like "OBJECT" or "STRING". + return self.init(type: nil, anyOf: schemas) + } } // MARK: - Codable Conformance @@ -406,6 +459,7 @@ extension Schema: Encodable { case maxItems case minimum case maximum + case anyOf case properties case requiredProperties = "required" case propertyOrdering diff --git a/FirebaseVertexAI/Sources/VertexLog.swift b/FirebaseVertexAI/Sources/VertexLog.swift index b98ed0f50f2..b3f5b04be64 100644 --- a/FirebaseVertexAI/Sources/VertexLog.swift +++ b/FirebaseVertexAI/Sources/VertexLog.swift @@ -34,6 +34,7 @@ enum VertexLog { // Generative Model Configuration case generativeModelInitialized = 1000 case unsupportedGeminiModel = 1001 + case invalidSchemaFormat = 1002 // Imagen Model Configuration case unsupportedImagenModel = 1200 diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 923ee1a238e..7758979050f 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -148,4 +148,88 @@ struct SchemaTests { #expect(rating >= 1, "Expected a rating >= 1, but got \(rating)") #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") } + + @Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1) + func generateContentAnyOfSchema(_ config: InstanceConfig) async throws { + struct MailingAddress: Decodable { + let streetAddress: String + let city: String + + // Canadian-specific + let province: String? + let postalCode: String? + + // U.S.-specific + let state: String? + let zipCode: String? + + var isCanadian: Bool { + return province != nil && postalCode != nil && state == nil && zipCode == nil + } + + var isAmerican: Bool { + return province == nil && postalCode == nil && state != nil && zipCode != nil + } + } + + let streetSchema = Schema.string(description: + "The civic number and street name, for example, '123 Main Street'.") + let citySchema = Schema.string(description: "The name of the city.") + let canadianAddressSchema = Schema.object( + properties: [ + "streetAddress": streetSchema, + "city": citySchema, + "province": .string(description: + "The 2-letter province or territory code, for example, 'ON', 'QC', or 'NU'."), + "postalCode": .string(description: "The postal code, for example, 'A1A 1A1'."), + ], + description: "A Canadian mailing address" + ) + let americanAddressSchema = Schema.object( + properties: [ + "streetAddress": streetSchema, + "city": citySchema, + "state": .string(description: + "The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'."), + "zipCode": .string(description: "The 5-digit ZIP code, for example, '12345'."), + ], + description: "A U.S. mailing address" + ) + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + generationConfig: GenerationConfig( + temperature: 0.0, + topP: 0.0, + topK: 1, + responseMIMEType: "application/json", + responseSchema: .array(items: .anyOf( + schemas: [canadianAddressSchema, americanAddressSchema] + )) + ), + safetySettings: safetySettings + ) + let prompt = """ + What are the mailing addresses for the University of Waterloo, UC Berkeley and Queen's U? + """ + let response = try await model.generateContent(prompt) + let text = try #require(response.text) + let jsonData = try #require(text.data(using: .utf8)) + let decodedAddresses = try JSONDecoder().decode([MailingAddress].self, from: jsonData) + try #require(decodedAddresses.count == 3, "Expected 3 JSON addresses, got \(text).") + let waterlooAddress = decodedAddresses[0] + #expect( + waterlooAddress.isCanadian, + "Expected Canadian University of Waterloo address, got \(waterlooAddress)." + ) + let berkeleyAddress = decodedAddresses[1] + #expect( + berkeleyAddress.isAmerican, + "Expected American UC Berkeley address, got \(berkeleyAddress)." + ) + let queensAddress = decodedAddresses[2] + #expect( + queensAddress.isCanadian, + "Expected Canadian Queen's University address, got \(queensAddress)." + ) + } } diff --git a/FirebaseVertexAI/Tests/Unit/Types/SchemaTests.swift b/FirebaseVertexAI/Tests/Unit/Types/SchemaTests.swift new file mode 100644 index 00000000000..d046fe86d6d --- /dev/null +++ b/FirebaseVertexAI/Tests/Unit/Types/SchemaTests.swift @@ -0,0 +1,472 @@ +// Copyright 2025 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 FirebaseVertexAI +import Foundation +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class SchemaTests: XCTestCase { + let encoder = JSONEncoder() + + override func setUp() { + super.setUp() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + } + + // MARK: - String Schema Encoding + + func testEncodeSchema_string_defaultParameters() throws { + let schema = Schema.string() + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "nullable" : false, + "type" : "STRING" + } + """) + } + + func testEncodeSchema_string_allOptions() throws { + let description = "Timestamp of the event." + let format = Schema.StringFormat.custom("date-time") + let schema = Schema.string(description: description, nullable: true, format: format) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "format" : "date-time", + "nullable" : true, + "type" : "STRING" + } + """) + } + + // MARK: - Enumeration Schema Encoding + + func testEncodeSchema_enumeration_defaultParameters() throws { + let values = ["RED", "GREEN", "BLUE"] + let schema = Schema.enumeration(values: values) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "enum" : [ + "RED", + "GREEN", + "BLUE" + ], + "format" : "enum", + "nullable" : false, + "type" : "STRING" + } + """) + } + + func testEncodeSchema_enumeration_allOptions() throws { + let values = ["NORTH", "SOUTH", "EAST", "WEST"] + let description = "Compass directions." + let schema = Schema.enumeration(values: values, description: description, nullable: true) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "enum" : [ + "NORTH", + "SOUTH", + "EAST", + "WEST" + ], + "format" : "enum", + "nullable" : true, + "type" : "STRING" + } + """) + } + + // MARK: - Float Schema Encoding + + func testEncodeSchema_float_defaultParameters() throws { + let schema = Schema.float() + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "format" : "float", + "nullable" : false, + "type" : "NUMBER" + } + """) + } + + func testEncodeSchema_float_allOptions() throws { + let description = "Temperature in Celsius." + let minimum: Float = -40.25 + let maximum: Float = 50.5 + let schema = Schema.float( + description: description, + nullable: true, + minimum: minimum, + maximum: maximum + ) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "format" : "float", + "maximum" : \(maximum), + "minimum" : \(minimum), + "nullable" : true, + "type" : "NUMBER" + } + """) + } + + // MARK: - Double Schema Encoding + + func testEncodeSchema_double_defaultParameters() throws { + let schema = Schema.double() + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "nullable" : false, + "type" : "NUMBER" + } + """) + } + + func testEncodeSchema_double_allOptions() throws { + let description = "Account balance." + let minimum = 0.01 + let maximum = 1_000_000.99 + let schema = Schema.double( + description: description, + nullable: true, + minimum: minimum, + maximum: maximum + ) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "maximum" : \(maximum), + "minimum" : \(minimum), + "nullable" : true, + "type" : "NUMBER" + } + """) + } + + // MARK: - Integer Schema Encoding + + func testEncodeSchema_integer_defaultParameters() throws { + let schema = Schema.integer() + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "nullable" : false, + "type" : "INTEGER" + } + """) + } + + func testEncodeSchema_integer_allOptions() throws { + let description = "User age." + let minimum = 0 + let maximum = 120 + let format = Schema.IntegerFormat.int32 + let schema = Schema.integer( + description: description, + nullable: true, + format: format, + minimum: minimum, + maximum: maximum + ) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "format" : "int32", + "maximum" : \(maximum), + "minimum" : \(minimum), + "nullable" : true, + "type" : "INTEGER" + } + """) + } + + // MARK: - Boolean Schema Encoding + + func testEncodeSchema_boolean_defaultParameters() throws { + let schema = Schema.boolean() + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + + XCTAssertEqual(json, """ + { + "nullable" : false, + "type" : "BOOLEAN" + } + """) + } + + func testEncodeSchema_boolean_allOptions() throws { + let description = "Is the user an administrator?" + let schema = Schema.boolean(description: description, nullable: true) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "nullable" : true, + "type" : "BOOLEAN" + } + """) + } + + // MARK: - Array Schema Encoding + + func testEncodeSchema_array_defaultParameters() throws { + let itemsSchema = Schema.string() + let schema = Schema.array(items: itemsSchema) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "items" : { + "nullable" : false, + "type" : "STRING" + }, + "nullable" : false, + "type" : "ARRAY" + } + """) + } + + func testEncodeSchema_array_allOptions() throws { + let itemsSchema = Schema.integer(format: .int64) + let description = "List of product IDs." + let minItems = 1 + let maxItems = 10 + let schema = Schema.array( + items: itemsSchema, + description: description, + nullable: true, + minItems: minItems, + maxItems: maxItems + ) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "items" : { + "format" : "int64", + "nullable" : false, + "type" : "INTEGER" + }, + "maxItems" : \(maxItems), + "minItems" : \(minItems), + "nullable" : true, + "type" : "ARRAY" + } + """) + } + + // MARK: - Object Schema Encoding + + func testEncodeSchema_object_defaultParameters() throws { + let properties: [String: Schema] = [ + "name": .string(), + "id": .integer(), + ] + let schema = Schema.object(properties: properties) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "nullable" : false, + "properties" : { + "id" : { + "nullable" : false, + "type" : "INTEGER" + }, + "name" : { + "nullable" : false, + "type" : "STRING" + } + }, + "required" : [ + "id", + "name" + ], + "type" : "OBJECT" + } + """) + } + + func testEncodeSchema_object_allOptions() throws { + let properties: [String: Schema] = [ + "firstName": .string(description: "Given name"), + "lastName": .string(description: "Family name"), + "age": .integer(minimum: 0), + "lastLogin": .string(format: .custom("date-time")), + ] + let optionalProperties = ["age", "lastLogin"] + let propertyOrdering = ["firstName", "lastName", "age", "lastLogin"] + let description = "User profile information." + let title = "User Profile" + let nullable = true + let schema = Schema.object( + properties: properties, + optionalProperties: optionalProperties, + propertyOrdering: propertyOrdering, + description: description, + title: title, + nullable: nullable + ) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "description" : "\(description)", + "nullable" : true, + "properties" : { + "age" : { + "minimum" : 0, + "nullable" : false, + "type" : "INTEGER" + }, + "firstName" : { + "description" : "Given name", + "nullable" : false, + "type" : "STRING" + }, + "lastLogin" : { + "format" : "date-time", + "nullable" : false, + "type" : "STRING" + }, + "lastName" : { + "description" : "Family name", + "nullable" : false, + "type" : "STRING" + } + }, + "propertyOrdering" : [ + "firstName", + "lastName", + "age", + "lastLogin" + ], + "required" : [ + "firstName", + "lastName" + ], + "title" : "\(title)", + "type" : "OBJECT" + } + """) + } + + // MARK: - AnyOf Schema Encoding + + func testEncodeSchema_anyOf() throws { + let schemas: [Schema] = [ + .string(description: "User ID as string"), + .integer(description: "User ID as integer"), + .object( + properties: ["userID": .string(), "detail": .string()], + optionalProperties: ["detail"] + ), + ] + let schema = Schema.anyOf(schemas: schemas) + + let jsonData = try encoder.encode(schema) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "anyOf" : [ + { + "description" : "User ID as string", + "nullable" : false, + "type" : "STRING" + }, + { + "description" : "User ID as integer", + "nullable" : false, + "type" : "INTEGER" + }, + { + "nullable" : false, + "properties" : { + "detail" : { + "nullable" : false, + "type" : "STRING" + }, + "userID" : { + "nullable" : false, + "type" : "STRING" + } + }, + "required" : [ + "userID" + ], + "type" : "OBJECT" + } + ] + } + """) + } +}