From 74448f79a596975c860aa074697e062c19bbbc9d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 11 Apr 2025 15:49:00 -0400 Subject: [PATCH 01/13] [Vertex AI] Add `anyOf` support to `Schema` --- .../Sources/Types/Internal/DataType.swift | 2 + .../Sources/Types/Public/Schema.swift | 34 ++++++++++++-- .../GenerateContentIntegrationTests.swift | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift index f995eacddf2..9b869e0d83a 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift @@ -33,6 +33,8 @@ enum DataType: String { /// An object type. case object = "OBJECT" + + case anyOf = "ANY_OF" } // MARK: - Codable Conformance diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index 1fb2886ed14..1a2457d2996 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -89,13 +89,15 @@ public final class Schema: Sendable { /// Properties of type `"OBJECT"`. public let properties: [String: Schema]? + public let anyOf: [Schema]? + /// Required properties of type `"OBJECT"`. public let requiredProperties: [String]? required init(type: DataType, format: String? = nil, description: String? = nil, - nullable: Bool = false, enumValues: [String]? = nil, items: Schema? = nil, - minItems: Int? = nil, maxItems: Int? = nil, - properties: [String: Schema]? = nil, requiredProperties: [String]? = nil) { + nullable: Bool? = nil, enumValues: [String]? = nil, items: Schema? = nil, + minItems: Int? = nil, maxItems: Int? = nil, properties: [String: Schema]? = nil, + anyOf: [Schema]? = nil, requiredProperties: [String]? = nil) { dataType = type self.format = format self.description = description @@ -105,9 +107,14 @@ public final class Schema: Sendable { self.minItems = minItems self.maxItems = maxItems self.properties = properties + self.anyOf = anyOf self.requiredProperties = requiredProperties } + public static func anyOf(schemas: [Schema]) -> Schema { + return self.init(type: .anyOf, anyOf: schemas) + } + /// Returns a `Schema` representing a string value. /// /// This schema instructs the model to produce data of type `"STRING"`, which is suitable for @@ -350,6 +357,27 @@ extension Schema: Encodable { case minItems case maxItems case properties + case anyOf case requiredProperties = "required" } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch dataType { + case .anyOf: + break + default: + try container.encode(dataType, forKey: .dataType) + } + try container.encodeIfPresent(format, forKey: .format) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(nullable, forKey: .nullable) + try container.encodeIfPresent(enumValues, forKey: .enumValues) + try container.encodeIfPresent(items, forKey: .items) + try container.encodeIfPresent(minItems, forKey: .minItems) + try container.encodeIfPresent(maxItems, forKey: .maxItems) + try container.encodeIfPresent(properties, forKey: .properties) + try container.encodeIfPresent(anyOf, forKey: .anyOf) + try container.encodeIfPresent(requiredProperties, forKey: .requiredProperties) + } } diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 95835a4ea7d..086142398e4 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -190,6 +190,52 @@ struct GenerateContentIntegrationTests { #expect(decodedJSON.count <= 5, "Expected at most 5 cities, but got \(decodedJSON.count)") } + @Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1) + func generateContentAnyOfSchema(_ config: InstanceConfig) async throws { + let canadianAddressSchema = Schema.object( + properties: [ + "streetAddress": .string(description: + "The civic number and street name, for example, '123 Main Street'."), + "city": .string(description: "The name of the city."), + "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": .string(description: + "The civic number and street name, for example, '123 Main Street'."), + "city": .string(description: "The name of the city."), + "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 UBC? + """ + let response = try await model.generateContent(prompt) + let text = try #require(response.text).trimmingCharacters(in: .whitespacesAndNewlines) + // TODO: Test that the JSON can be decoded. + print("JSON: \(text)") + } + // MARK: Streaming Tests @Test(arguments: InstanceConfig.allConfigs) From 784cf30da6c135c46c919bb408a85c57ce7bdb82 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 11 Apr 2025 15:58:59 -0400 Subject: [PATCH 02/13] Remove trailing commas --- .../GenerateContentIntegrationTests.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 086142398e4..b8f3b106c3f 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -192,27 +192,28 @@ struct GenerateContentIntegrationTests { @Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1) func generateContentAnyOfSchema(_ config: InstanceConfig) async throws { + 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": .string(description: - "The civic number and street name, for example, '123 Main Street'."), - "city": .string(description: "The name of the city."), + "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", + description: "A Canadian mailing address" ) let americanAddressSchema = Schema.object( properties: [ - "streetAddress": .string(description: - "The civic number and street name, for example, '123 Main Street'."), - "city": .string(description: "The name of the city."), + "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", + description: "A U.S. mailing address" ) let model = VertexAI.componentInstance(config).generativeModel( modelName: ModelNames.gemini2Flash, From 181e936216a4d721d861eaf5aeec57a54eef1d9e Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 21:36:02 -0400 Subject: [PATCH 03/13] Add decoding to integration test --- .../GenerateContentIntegrationTests.swift | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index b8f3b106c3f..7ff362af060 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -192,6 +192,27 @@ struct GenerateContentIntegrationTests { @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.") @@ -229,12 +250,28 @@ struct GenerateContentIntegrationTests { safetySettings: safetySettings ) let prompt = """ - What are the mailing addresses for the University of Waterloo, UC Berkeley and UBC? + 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).trimmingCharacters(in: .whitespacesAndNewlines) - // TODO: Test that the JSON can be decoded. - print("JSON: \(text)") + 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)." + ) } // MARK: Streaming Tests From abed9a52e81127d333af9d8e81b211597fc61a5f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 21:53:22 -0400 Subject: [PATCH 04/13] Add missing `developerV1Beta` config from `allConfigsExceptDeveloperV1` --- .../Tests/TestApp/Tests/Utilities/InstanceConfig.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index bb4a0766eac..255918f83c4 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -58,6 +58,7 @@ struct InstanceConfig { vertexV1Staging, vertexV1Beta, vertexV1BetaStaging, + developerV1Beta, developerV1BetaSpark, ] From 670e9d432d3dfb1589341e1c434c01f6eb7cf310 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 21:54:57 -0400 Subject: [PATCH 05/13] Fix `Schema` decoding after merge --- .../Sources/Types/Public/Schema.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index c51951175d3..a3b49aecc5b 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -138,16 +138,12 @@ public final class Schema: Sendable { self.maxItems = maxItems self.minimum = minimum self.maximum = maximum - self.properties = properties self.anyOf = anyOf + self.properties = properties self.requiredProperties = requiredProperties self.propertyOrdering = propertyOrdering } - public static func anyOf(schemas: [Schema]) -> Schema { - return self.init(type: .anyOf, anyOf: schemas) - } - /// Returns a `Schema` representing a string value. /// /// This schema instructs the model to produce data of type `"STRING"`, which is suitable for @@ -395,6 +391,10 @@ public final class Schema: Sendable { propertyOrdering: propertyOrdering ) } + + public static func anyOf(schemas: [Schema]) -> Schema { + return self.init(type: .anyOf, anyOf: schemas) + } } // MARK: - Codable Conformance @@ -413,8 +413,8 @@ extension Schema: Encodable { case maxItems case minimum case maximum - case properties case anyOf + case properties case requiredProperties = "required" case propertyOrdering } @@ -429,13 +429,17 @@ extension Schema: Encodable { } try container.encodeIfPresent(format, forKey: .format) try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(nullable, forKey: .nullable) try container.encodeIfPresent(enumValues, forKey: .enumValues) try container.encodeIfPresent(items, forKey: .items) try container.encodeIfPresent(minItems, forKey: .minItems) try container.encodeIfPresent(maxItems, forKey: .maxItems) - try container.encodeIfPresent(properties, forKey: .properties) + try container.encodeIfPresent(minimum, forKey: .minimum) + try container.encodeIfPresent(maximum, forKey: .maximum) try container.encodeIfPresent(anyOf, forKey: .anyOf) + try container.encodeIfPresent(properties, forKey: .properties) try container.encodeIfPresent(requiredProperties, forKey: .requiredProperties) + try container.encodeIfPresent(propertyOrdering, forKey: .requiredProperties) } } From 2068dab34e424f3134af1a62bbf4e972356b812f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 21:58:58 -0400 Subject: [PATCH 06/13] Move from `GenerateContentIntegrationTests` to `SchemaTests` --- .../GenerateContentIntegrationTests.swift | 84 ------------------- .../Tests/Integration/SchemaTests.swift | 84 +++++++++++++++++++ 2 files changed, 84 insertions(+), 84 deletions(-) diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index d23ada740e4..f1650f26471 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -165,90 +165,6 @@ struct GenerateContentIntegrationTests { #endif // canImport(UIKit) } - @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)." - ) - } - // MARK: Streaming Tests @Test(arguments: InstanceConfig.allConfigs) 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)." + ) + } } From b8036a81fdd335213c045dcd62c0003f6ba3963e Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 22:17:45 -0400 Subject: [PATCH 07/13] Fix encoding and add documentation --- .../Sources/Types/Internal/DataType.swift | 3 ++ .../Sources/Types/Public/Schema.swift | 44 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift index 9b869e0d83a..8fe96b86d16 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift @@ -34,6 +34,9 @@ enum DataType: String { /// An object type. case object = "OBJECT" + /// One or more other types. + /// + /// This is a special case and is not encoded directly as a type string in `Schema`. case anyOf = "ANY_OF" } diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index a3b49aecc5b..90ec2e7ca2a 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -106,6 +106,14 @@ 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` @@ -392,7 +400,36 @@ public final class Schema: Sendable { ) } + /// 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. + /// + /// - Parameter 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. + /// - Returns: A `Schema` configured with the `anyOf` constraint. public static func anyOf(schemas: [Schema]) -> Schema { + guard !schemas.isEmpty else { + fatalError("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". + // We use a placeholder internal type `.anyOf` which is filtered out during encoding. return self.init(type: .anyOf, anyOf: schemas) } } @@ -421,10 +458,13 @@ extension Schema: Encodable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + // Only encode the 'type' key if it's *not* an 'anyOf' schema. + // The presence of the 'anyOf' key implies the type constraint. switch dataType { case .anyOf: - break + break // Do not encode 'type' for 'anyOf'. default: + // Encode 'type' for all other standard schema types. try container.encode(dataType, forKey: .dataType) } try container.encodeIfPresent(format, forKey: .format) @@ -440,6 +480,6 @@ extension Schema: Encodable { try container.encodeIfPresent(anyOf, forKey: .anyOf) try container.encodeIfPresent(properties, forKey: .properties) try container.encodeIfPresent(requiredProperties, forKey: .requiredProperties) - try container.encodeIfPresent(propertyOrdering, forKey: .requiredProperties) + try container.encodeIfPresent(propertyOrdering, forKey: .propertyOrdering) } } From f5c2929e073914e02f8c662073a10199050d44c4 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 22:23:55 -0400 Subject: [PATCH 08/13] Add missing docs --- FirebaseVertexAI/Sources/Types/Public/Schema.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index 90ec2e7ca2a..28a971427f8 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -289,6 +289,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 { @@ -373,8 +377,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] = [], @@ -420,9 +427,9 @@ public final class Schema: Sendable { /// ``` /// The generated data could be decoded based on which schema it matches. /// - /// - Parameter 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. - /// - Returns: A `Schema` configured with the `anyOf` constraint. + /// - 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 { guard !schemas.isEmpty else { fatalError("The `anyOf` schemas array cannot be empty.") From d317a5854914e850747d3d928522cdac80d0c4ab Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Apr 2025 22:40:02 -0400 Subject: [PATCH 09/13] Update changelog entry --- FirebaseVertexAI/CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index 936109166d0..f729c288a02 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -6,8 +6,17 @@ 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 multiple new `Schema` fields. (#14671) + - `minItems` / `maxItems`: Specifies the minimum and maximum number of items + to generate in an array `Schema`. + - `title`: A human-readable name/summary for the schema, helping to document + its purpose. + - `minimum` / `maximum`: Specifies the minimum or maximum values for numeric + types (`integer`, `float`, `double`). + - `anyOf`: Allows specifying that the generated data may be *any* of the + provided sub-schemas. + - `propertyOrdering`: Specifies the order for keys in the generated JSON + output for object schemas. - [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) From 2aaff06849c79c5f24fc75933da1bac825724f9c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Apr 2025 20:28:44 -0400 Subject: [PATCH 10/13] Add unit tests --- .../Sources/Types/Public/Schema.swift | 10 +- .../Tests/Unit/Types/SchemaTests.swift | 472 ++++++++++++++++++ 2 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 FirebaseVertexAI/Tests/Unit/Types/SchemaTests.swift diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index 28a971427f8..cb5efdf32db 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -130,11 +130,11 @@ public final class Schema: Sendable { 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, anyOf: [Schema]? = nil, properties: [String: Schema]? = nil, - requiredProperties: [String]? = nil, propertyOrdering: [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) { dataType = type self.format = format self.description = description 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" + } + ] + } + """) + } +} From 3fe1b0370681419a9204fdf652add918fcac761d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Apr 2025 20:39:33 -0400 Subject: [PATCH 11/13] Log instead of fatalError --- FirebaseVertexAI/Sources/Types/Public/Schema.swift | 4 ++-- FirebaseVertexAI/Sources/VertexLog.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index cb5efdf32db..70e6072eaca 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Schema.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Schema.swift @@ -431,8 +431,8 @@ public final class Schema: Sendable { /// - 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 { - guard !schemas.isEmpty else { - fatalError("The `anyOf` schemas array cannot be empty.") + 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". 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 From 01f4555b4dac1aed93118fa156dae9aa9b4ada2d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Apr 2025 20:39:50 -0400 Subject: [PATCH 12/13] Update changelog entry --- FirebaseVertexAI/CHANGELOG.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index f729c288a02..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,17 +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 multiple new `Schema` fields. (#14671) - - `minItems` / `maxItems`: Specifies the minimum and maximum number of items - to generate in an array `Schema`. - - `title`: A human-readable name/summary for the schema, helping to document - its purpose. - - `minimum` / `maximum`: Specifies the minimum or maximum values for numeric - types (`integer`, `float`, `double`). - - `anyOf`: Allows specifying that the generated data may be *any* of the - provided sub-schemas. - - `propertyOrdering`: Specifies the order for keys in the generated JSON - output for object schemas. +- [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) From 472796665c3204e0ffc8c05bb62c608adc58e482 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Apr 2025 21:03:46 -0400 Subject: [PATCH 13/13] Refactor to remove need for custom `encode(to:)` implementation --- .../Sources/Types/Internal/DataType.swift | 5 --- .../Sources/Types/Public/Schema.swift | 39 ++++--------------- 2 files changed, 7 insertions(+), 37 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift index 8fe96b86d16..f995eacddf2 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/DataType.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/DataType.swift @@ -33,11 +33,6 @@ enum DataType: String { /// An object type. case object = "OBJECT" - - /// One or more other types. - /// - /// This is a special case and is not encoded directly as a type string in `Schema`. - case anyOf = "ANY_OF" } // MARK: - Codable Conformance diff --git a/FirebaseVertexAI/Sources/Types/Public/Schema.swift b/FirebaseVertexAI/Sources/Types/Public/Schema.swift index 70e6072eaca..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? @@ -129,12 +130,14 @@ public final class Schema: Sendable { /// serialization. public let propertyOrdering: [String]? - required init(type: DataType, format: String? = nil, description: 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 @@ -436,8 +439,7 @@ public final class Schema: Sendable { } // 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". - // We use a placeholder internal type `.anyOf` which is filtered out during encoding. - return self.init(type: .anyOf, anyOf: schemas) + return self.init(type: nil, anyOf: schemas) } } @@ -462,31 +464,4 @@ extension Schema: Encodable { case requiredProperties = "required" case propertyOrdering } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - // Only encode the 'type' key if it's *not* an 'anyOf' schema. - // The presence of the 'anyOf' key implies the type constraint. - switch dataType { - case .anyOf: - break // Do not encode 'type' for 'anyOf'. - default: - // Encode 'type' for all other standard schema types. - try container.encode(dataType, forKey: .dataType) - } - try container.encodeIfPresent(format, forKey: .format) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(title, forKey: .title) - try container.encodeIfPresent(nullable, forKey: .nullable) - try container.encodeIfPresent(enumValues, forKey: .enumValues) - try container.encodeIfPresent(items, forKey: .items) - try container.encodeIfPresent(minItems, forKey: .minItems) - try container.encodeIfPresent(maxItems, forKey: .maxItems) - try container.encodeIfPresent(minimum, forKey: .minimum) - try container.encodeIfPresent(maximum, forKey: .maximum) - try container.encodeIfPresent(anyOf, forKey: .anyOf) - try container.encodeIfPresent(properties, forKey: .properties) - try container.encodeIfPresent(requiredProperties, forKey: .requiredProperties) - try container.encodeIfPresent(propertyOrdering, forKey: .propertyOrdering) - } }