Skip to content

Commit 6c236d8

Browse files
authored
[Vertex AI] Add anyOf support to Schema (#14708)
1 parent fff812f commit 6c236d8

File tree

5 files changed

+622
-10
lines changed

5 files changed

+622
-10
lines changed

FirebaseVertexAI/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
Note: This feature is in Public Preview and relies on experimental models,
77
which means that it is not subject to any SLA or deprecation policy and could
88
change in backwards-incompatible ways.
9-
- [added] Added support for specifying the minimum and maximum number of items
10-
(`minItems` / `maxItems`) to generate in an array `Schema`. (#14671)
9+
- [added] Added support for more `Schema` fields: `minItems`/`maxItems` (array
10+
size limits), `title` (schema name), `minimum`/`maximum` (numeric ranges),
11+
`anyOf` (select from sub-schemas), and `propertyOrdering` (JSON key order). (#14647)
1112
- [fixed] Fixed an issue where network requests would fail in the iOS 18.4
1213
simulator due to a `URLSession` bug introduced in Xcode 16.3. (#14677)
1314

FirebaseVertexAI/Sources/Types/Public/Schema.swift

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ public final class Schema: Sendable {
6060
let rawValue: String
6161
}
6262

63-
let dataType: DataType
63+
// May only be nil for `anyOf` schemas, which do not have an explicit `type` in the OpenAPI spec.
64+
let dataType: DataType?
6465

6566
/// The data type.
66-
public var type: String { dataType.rawValue }
67+
public var type: String { dataType?.rawValue ?? "UNSPECIFIED" }
6768

6869
/// The format of the data.
6970
public let format: String?
@@ -106,6 +107,16 @@ public final class Schema: Sendable {
106107
/// property's type and constraints.
107108
public let properties: [String: Schema]?
108109

110+
/// An array of `Schema` objects. The generated data must be valid against *any* (one or more)
111+
/// of the schemas listed in this array. This allows specifying multiple possible structures or
112+
/// types for a single field.
113+
///
114+
/// For example, a value could be either a `String` or an `Integer`:
115+
/// ```
116+
/// Schema.anyOf(schemas: [.string(), .integer()])
117+
/// ```
118+
public let anyOf: [Schema]?
119+
109120
/// An array of strings, where each string is the name of a property defined in the `properties`
110121
/// dictionary that must be present in the generated object. If a property is listed here, the
111122
/// model must include it in the output.
@@ -119,12 +130,14 @@ public final class Schema: Sendable {
119130
/// serialization.
120131
public let propertyOrdering: [String]?
121132

122-
required init(type: DataType, format: String? = nil, description: String? = nil,
123-
title: String? = nil,
124-
nullable: Bool = false, enumValues: [String]? = nil, items: Schema? = nil,
125-
minItems: Int? = nil, maxItems: Int? = nil, minimum: Double? = nil,
126-
maximum: Double? = nil, properties: [String: Schema]? = nil,
127-
requiredProperties: [String]? = nil, propertyOrdering: [String]? = nil) {
133+
required init(type: DataType?, format: String? = nil, description: String? = nil,
134+
title: String? = nil, nullable: Bool? = nil, enumValues: [String]? = nil,
135+
items: Schema? = nil, minItems: Int? = nil, maxItems: Int? = nil,
136+
minimum: Double? = nil, maximum: Double? = nil, anyOf: [Schema]? = nil,
137+
properties: [String: Schema]? = nil, requiredProperties: [String]? = nil,
138+
propertyOrdering: [String]? = nil) {
139+
precondition(type != nil || anyOf != nil,
140+
"A schema must have either a `type` or an `anyOf` array of sub-schemas.")
128141
dataType = type
129142
self.format = format
130143
self.description = description
@@ -136,6 +149,7 @@ public final class Schema: Sendable {
136149
self.maxItems = maxItems
137150
self.minimum = minimum
138151
self.maximum = maximum
152+
self.anyOf = anyOf
139153
self.properties = properties
140154
self.requiredProperties = requiredProperties
141155
self.propertyOrdering = propertyOrdering
@@ -278,6 +292,10 @@ public final class Schema: Sendable {
278292
/// - format: An optional modifier describing the expected format of the integer. Currently the
279293
/// formats ``IntegerFormat/int32`` and ``IntegerFormat/int64`` are supported; custom values
280294
/// may be specified using ``IntegerFormat/custom(_:)`` but may be ignored by the model.
295+
/// - minimum: If specified, instructs the model that the value should be greater than or
296+
/// equal to the specified minimum.
297+
/// - maximum: If specified, instructs the model that the value should be less than or equal
298+
/// to the specified maximum.
281299
public static func integer(description: String? = nil, nullable: Bool = false,
282300
format: IntegerFormat? = nil,
283301
minimum: Int? = nil, maximum: Int? = nil) -> Schema {
@@ -362,8 +380,11 @@ public final class Schema: Sendable {
362380
/// - optionalProperties: A list of property names that may be be omitted in objects generated
363381
/// by the model; these names must correspond to the keys provided in the `properties`
364382
/// dictionary and may be an empty list.
383+
/// - propertyOrdering: An optional hint to the model suggesting the order for keys in the
384+
/// generated JSON string. See ``propertyOrdering`` for details.
365385
/// - description: An optional description of what the object should contain or represent; may
366386
/// use Markdown format.
387+
/// - title: An optional human-readable name/summary for the object schema.
367388
/// - nullable: If `true`, instructs the model that it may return `null` instead of an object;
368389
/// defaults to `false`, enforcing that an object is returned.
369390
public static func object(properties: [String: Schema], optionalProperties: [String] = [],
@@ -388,6 +409,38 @@ public final class Schema: Sendable {
388409
propertyOrdering: propertyOrdering
389410
)
390411
}
412+
413+
/// Returns a `Schema` representing a value that must conform to *any* (one or more) of the
414+
/// provided sub-schemas.
415+
///
416+
/// This schema instructs the model to produce data that is valid against at least one of the
417+
/// schemas listed in the `schemas` array. This is useful when a field can accept multiple
418+
/// distinct types or structures.
419+
///
420+
/// **Example:** A field that can hold either a simple user ID (integer) or a detailed user
421+
/// object.
422+
/// ```
423+
/// Schema.anyOf(schemas: [
424+
/// .integer(description: "User ID"),
425+
/// .object(properties: [
426+
/// "userId": .integer(),
427+
/// "userName": .string()
428+
/// ], description: "Detailed User Object")
429+
/// ])
430+
/// ```
431+
/// The generated data could be decoded based on which schema it matches.
432+
///
433+
/// - Parameters:
434+
/// - schemas: An array of `Schema` objects. The generated data must be valid against at least
435+
/// one of these schemas. The array must not be empty.
436+
public static func anyOf(schemas: [Schema]) -> Schema {
437+
if schemas.isEmpty {
438+
VertexLog.error(code: .invalidSchemaFormat, "The `anyOf` schemas array cannot be empty.")
439+
}
440+
// Note: The 'type' for an 'anyOf' schema is implicitly defined by the presence of the
441+
// 'anyOf' keyword and doesn't have a specific explicit type like "OBJECT" or "STRING".
442+
return self.init(type: nil, anyOf: schemas)
443+
}
391444
}
392445

393446
// MARK: - Codable Conformance
@@ -406,6 +459,7 @@ extension Schema: Encodable {
406459
case maxItems
407460
case minimum
408461
case maximum
462+
case anyOf
409463
case properties
410464
case requiredProperties = "required"
411465
case propertyOrdering

FirebaseVertexAI/Sources/VertexLog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ enum VertexLog {
3434
// Generative Model Configuration
3535
case generativeModelInitialized = 1000
3636
case unsupportedGeminiModel = 1001
37+
case invalidSchemaFormat = 1002
3738

3839
// Imagen Model Configuration
3940
case unsupportedImagenModel = 1200

FirebaseVertexAI/Tests/TestApp/Tests/Integration/SchemaTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,88 @@ struct SchemaTests {
148148
#expect(rating >= 1, "Expected a rating >= 1, but got \(rating)")
149149
#expect(rating <= 5, "Expected a rating <= 5, but got \(rating)")
150150
}
151+
152+
@Test(arguments: InstanceConfig.allConfigsExceptDeveloperV1)
153+
func generateContentAnyOfSchema(_ config: InstanceConfig) async throws {
154+
struct MailingAddress: Decodable {
155+
let streetAddress: String
156+
let city: String
157+
158+
// Canadian-specific
159+
let province: String?
160+
let postalCode: String?
161+
162+
// U.S.-specific
163+
let state: String?
164+
let zipCode: String?
165+
166+
var isCanadian: Bool {
167+
return province != nil && postalCode != nil && state == nil && zipCode == nil
168+
}
169+
170+
var isAmerican: Bool {
171+
return province == nil && postalCode == nil && state != nil && zipCode != nil
172+
}
173+
}
174+
175+
let streetSchema = Schema.string(description:
176+
"The civic number and street name, for example, '123 Main Street'.")
177+
let citySchema = Schema.string(description: "The name of the city.")
178+
let canadianAddressSchema = Schema.object(
179+
properties: [
180+
"streetAddress": streetSchema,
181+
"city": citySchema,
182+
"province": .string(description:
183+
"The 2-letter province or territory code, for example, 'ON', 'QC', or 'NU'."),
184+
"postalCode": .string(description: "The postal code, for example, 'A1A 1A1'."),
185+
],
186+
description: "A Canadian mailing address"
187+
)
188+
let americanAddressSchema = Schema.object(
189+
properties: [
190+
"streetAddress": streetSchema,
191+
"city": citySchema,
192+
"state": .string(description:
193+
"The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'."),
194+
"zipCode": .string(description: "The 5-digit ZIP code, for example, '12345'."),
195+
],
196+
description: "A U.S. mailing address"
197+
)
198+
let model = VertexAI.componentInstance(config).generativeModel(
199+
modelName: ModelNames.gemini2Flash,
200+
generationConfig: GenerationConfig(
201+
temperature: 0.0,
202+
topP: 0.0,
203+
topK: 1,
204+
responseMIMEType: "application/json",
205+
responseSchema: .array(items: .anyOf(
206+
schemas: [canadianAddressSchema, americanAddressSchema]
207+
))
208+
),
209+
safetySettings: safetySettings
210+
)
211+
let prompt = """
212+
What are the mailing addresses for the University of Waterloo, UC Berkeley and Queen's U?
213+
"""
214+
let response = try await model.generateContent(prompt)
215+
let text = try #require(response.text)
216+
let jsonData = try #require(text.data(using: .utf8))
217+
let decodedAddresses = try JSONDecoder().decode([MailingAddress].self, from: jsonData)
218+
try #require(decodedAddresses.count == 3, "Expected 3 JSON addresses, got \(text).")
219+
let waterlooAddress = decodedAddresses[0]
220+
#expect(
221+
waterlooAddress.isCanadian,
222+
"Expected Canadian University of Waterloo address, got \(waterlooAddress)."
223+
)
224+
let berkeleyAddress = decodedAddresses[1]
225+
#expect(
226+
berkeleyAddress.isAmerican,
227+
"Expected American UC Berkeley address, got \(berkeleyAddress)."
228+
)
229+
let queensAddress = decodedAddresses[2]
230+
#expect(
231+
queensAddress.isCanadian,
232+
"Expected Canadian Queen's University address, got \(queensAddress)."
233+
)
234+
}
151235
}

0 commit comments

Comments
 (0)