From e150802035530b4b7ec77949853125c7b71572e9 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Sun, 23 Nov 2025 21:00:33 -0500 Subject: [PATCH 01/25] [Firebase AI] Add `Generable` scaffolding --- .../ConvertibleFromGeneratedContent.swift | 41 ++++ .../ConvertibleToGeneratedContent.swift | 42 ++++ .../Types/Public/Generable/Generable.swift | 196 +++++++++++++++++ .../Public/Generable/GeneratedContent.swift | 197 ++++++++++++++++++ .../Public/Generable/GenerationGuide.swift | 16 ++ .../Public/Generable/GenerationSchema.swift | 163 +++++++++++++++ 6 files changed, 655 insertions(+) create mode 100644 FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift create mode 100644 FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift create mode 100644 FirebaseAI/Sources/Types/Public/Generable/Generable.swift create mode 100644 FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift create mode 100644 FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift create mode 100644 FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift new file mode 100644 index 00000000000..4967c35963d --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift @@ -0,0 +1,41 @@ +// 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. + +/// A type that can be initialized from generated content. +public protocol ConvertibleFromGeneratedContent: SendableMetatype { + /// Creates an instance from content generated by a model. + /// + /// Conformance to this protocol is provided by the `@Generable` macro. A manual implementation + /// may be used to map values onto properties using different names. To manually initialize your + /// type from generated content, decode the values as shown below: + /// + /// ```swift + /// struct Person: ConvertibleFromGeneratedContent { + /// var name: String + /// var age: Int + /// + /// init(_ content: GeneratedContent) { + /// self.name = try content.value(forProperty: "firstName") + /// self.age = try content.value(forProperty: "ageInYears") + /// } + /// } + /// ``` + /// + /// - Important: If your type also conforms to ``ConvertibleToGeneratedContent``, it is critical + /// that this implementation be symmetrical with + /// ``ConvertibleToGeneratedContent/generatedContent``. + /// + /// - SeeAlso: `@Generable` macro ``Generable(description:)`` + init(_ content: GeneratedContent) throws +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift new file mode 100644 index 00000000000..546c1b5c36a --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift @@ -0,0 +1,42 @@ +// 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. + +/// A type that can be converted to generated content. +public protocol ConvertibleToGeneratedContent { + /// This instance represented as generated content. + /// + /// Conformance to this protocol is provided by the `@Generable` macro. A manual implementation + /// may be used to map values onto properties using different names. Use the generated content + /// property as shown below, to manually return a new ``GeneratedContent`` with the properties + /// you specify. + /// + /// ```swift + /// struct Person: ConvertibleToGeneratedContent { + /// var name: String + /// var age: Int + /// + /// var generatedContent: GeneratedContent { + /// GeneratedContent(properties: [ + /// "firstName": name, + /// "ageInYears": age + /// ]) + /// } + /// } + /// ``` + /// + /// - Important: If your type also conforms to ``ConvertibleFromGeneratedContent``, it is + /// critical that this implementation be symmetrical with + /// ``ConvertibleFromGeneratedContent/init(_:)``. + var generatedContent: GeneratedContent { get } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift new file mode 100644 index 00000000000..f4e658039cf --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -0,0 +1,196 @@ +// 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 Foundation + +/// A type that the model uses when responding to prompts. +/// +/// Annotate your Swift structure or enumeration with the `@Generable` macro to allow the model to +/// respond to prompts by generating an instance of your type. Use the `@Guide` macro to provide +/// natural language descriptions of your properties, and programmatically control the values that +/// the model can generate. +/// +/// ```swift +/// @Generable +/// struct SearchSuggestions { +/// @Guide(description: "A list of suggested search terms", .count(4)) +/// var searchTerms: [SearchTerm] +/// +/// @Generable +/// struct SearchTerm { +/// // Use a generation identifier for data structures the framework generates. +/// var id: GenerationID +/// +/// @Guide(description: "A 2 or 3 word search term, like 'Beautiful sunsets'") +/// var searchTerm: String +/// } +/// } +/// ``` +/// - SeeAlso: `@Generable` macro ``Generable(description:)`` and `@Guide` macro +/// ``Guide(description:)``. +public protocol Generable: ConvertibleFromGeneratedContent, ConvertibleToGeneratedContent { + /// An instance of the generation schema. + static var generationSchema: GenerationSchema { get } +} + +extension Optional where Wrapped: Generable {} + +extension Optional: ConvertibleToGeneratedContent where Wrapped: ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + guard let self else { return GeneratedContent(kind: .null) } + + return GeneratedContent(self) + } +} + +extension Bool: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .boolean, source: "Bool") + } + + public init(_ content: GeneratedContent) throws { + guard case let .bool(value) = content.kind else { + // TODO: Determine the correct error to throw. + fatalError("Expected a boolean but found \(content.kind)") + } + self = value + } + + public var generatedContent: GeneratedContent { + return GeneratedContent(kind: .bool(self)) + } +} + +extension String: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .string, source: "String") + } + + public init(_ content: GeneratedContent) throws { + guard case let .string(value) = content.kind else { + // TODO: Determine the correct error to throw. + fatalError("Expected a string but found \(content.kind)") + } + self = value + } + + public var generatedContent: GeneratedContent { + return GeneratedContent(kind: .string(self)) + } +} + +extension Int: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .integer, source: "Int") + } + + public init(_ content: GeneratedContent) throws { + // TODO: Determine the correct errors to throw. + guard case let .number(value) = content.kind else { + fatalError("Expected a number but found \(content.kind)") + } + guard let integer = Int(exactly: value) else { + fatalError("Expected an integer but found \(value)") + } + self = integer + } + + public var generatedContent: GeneratedContent { + return GeneratedContent(kind: .number(Double(self))) + } +} + +extension Float: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .double, source: "Number") + } + + public init(_ content: GeneratedContent) throws { + // TODO: Determine the correct error to throw. + guard case let .number(value) = content.kind else { + fatalError("Expected a number but found \(content.kind)") + } + guard let float = Float(exactly: value) else { + fatalError("Expected a float but found \(value)") + } + self = float + } + + public var generatedContent: GeneratedContent { + return GeneratedContent(kind: .number(Double(self))) + } +} + +extension Double: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .double, source: "Number") + } + + public init(_ content: GeneratedContent) throws { + // TODO: Determine the correct error to throw. + guard case let .number(value) = content.kind else { + fatalError("Expected a number but found \(content.kind)") + } + guard let double = Double(exactly: value) else { + fatalError("Expected a double but found \(value)") + } + self = double + } + + public var generatedContent: GeneratedContent { + return GeneratedContent(kind: .number(self)) + } +} + +extension Decimal: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .double, source: "Number") + } + + public init(_ content: GeneratedContent) throws { + // TODO: Determine the correct error to throw. + guard case let .number(value) = content.kind else { + fatalError("Expected a number but found \(content.kind)") + } + self = Decimal(value) + } + + public var generatedContent: GeneratedContent { + let doubleValue = (self as NSDecimalNumber).doubleValue + return GeneratedContent(kind: .number(doubleValue)) + } +} + +extension Array: Generable where Element: Generable { + public static var generationSchema: GenerationSchema { + GenerationSchema(kind: .array(item: Element.self), source: String(describing: self)) + } +} + +extension Array: ConvertibleToGeneratedContent where Element: ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + let values = map { $0.generatedContent } + return GeneratedContent(kind: .array(values)) + } +} + +extension Array: ConvertibleFromGeneratedContent where Element: ConvertibleFromGeneratedContent { + public init(_ content: GeneratedContent) throws { + // TODO: Determine the correct error to throw. + guard case let .array(values) = content.kind else { + fatalError("Expected an array but found \(content.kind)") + } + self = try values.map { try Element($0) } + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift new file mode 100644 index 00000000000..122d3b8d790 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift @@ -0,0 +1,197 @@ +// 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. + +/// A type that represents structured, generated content. +/// +/// Generated content may contain a single value, an array, or key-value pairs with unique keys. +public struct GeneratedContent: Sendable, Generable { + /// The kind representation of this generated content. + /// + /// This property provides access to the content in a strongly-typed enum representation, + /// preserving the hierarchical structure of the data and the data's ``GenerationID`` ids. + public let kind: Kind + + /// An instance of the generation schema. + public static var generationSchema: GenerationSchema { + // Return a schema equivalent to any legal JSON, i.e.: + // { + // "anyOf" : [ + // { + // "additionalProperties" : { + // "$ref" : "#" + // }, + // "type" : "object" + // }, + // { + // "items" : { + // "$ref" : "#" + // }, + // "type" : "array" + // }, + // { + // "type" : "boolean" + // }, + // { + // "type" : "number" + // }, + // { + // "type" : "string" + // } + // ], + // "description" : "Any legal JSON", + // "title" : "GeneratedContent" + // } + fatalError("`GeneratedContent.generationSchema` is not implemented.") + } + + init(kind: Kind) { + self.kind = kind + } + + /// Creates generated content from another value. + /// + /// This is used to satisfy `Generable.init(_:)`. + public init(_ content: GeneratedContent) throws { + self = content + } + + /// A representation of this instance. + public var generatedContent: GeneratedContent { self } + + /// Creates generated content representing a structure with the properties you specify. + /// + /// The order of properties is important. For ``Generable`` types, the order must match the order + /// properties in the types `schema`. + public init(properties: KeyValuePairs) { + fatalError("`GeneratedContent.init(properties:)` is not implemented.") + } + + /// Creates new generated content from the key-value pairs in the given sequence, using a + /// combining closure to determine the value for any duplicate keys. + /// + /// The order of properties is important. For ``Generable`` types, the order must match the order + /// properties in the types `schema`. + /// + /// You use this initializer to create generated content when you have a sequence of key-value + /// tuples that might have duplicate keys. As the content is built, the initializer calls the + /// `combine` closure with the current and new values for any duplicate keys. Pass a closure as + /// `combine` that returns the value to use in the resulting content: The closure can choose + /// between the two values, combine them to produce a new value, or even throw an error. + /// + /// The following example shows how to choose the first and last values for any duplicate keys: + /// + /// ```swift + /// let content = GeneratedContent( + /// properties: [("name", "John"), ("name", "Jane"), ("married", true)], + /// uniquingKeysWith: { (first, _) in first } + /// ) + /// // GeneratedContent(["name": "John", "married": true]) + /// ``` + /// + /// - Parameters: + /// - properties: A sequence of key-value pairs to use for the new content. + /// - id: A unique id associated with ``GeneratedContent``. + /// - combine: A closure that is called with the values to resolve any duplicates + /// keys that are encountered. The closure returns the desired value for the final content. + public init(properties: S, + uniquingKeysWith combine: (GeneratedContent, GeneratedContent) throws + -> some ConvertibleToGeneratedContent) rethrows where S: Sequence, S.Element == ( + String, + any ConvertibleToGeneratedContent + ) { + var propertyNames = [String]() + var propertyMap = [String: GeneratedContent]() + for (key, value) in properties { + if !propertyNames.contains(key) { + propertyNames.append(key) + propertyMap[key] = value.generatedContent + } else { + guard let existingProperty = propertyMap[key] else { + // TODO: Figure out an error to throw + fatalError() + } + let deduplicatedProperty = try combine(existingProperty, value.generatedContent) + propertyMap[key] = deduplicatedProperty.generatedContent + } + } + + kind = .structure(properties: propertyMap, orderedKeys: propertyNames) + } + + /// Creates content representing an array of elements you specify. + public init(elements: S) where S: Sequence, S.Element == any ConvertibleToGeneratedContent { + fatalError("`GeneratedContent.init(elements:)` is not implemented.") + } + + /// Creates content that contains a single value. + /// + /// - Parameters: + /// - value: The underlying value. + public init(_ value: some ConvertibleToGeneratedContent) { + self = value.generatedContent + } + + /// Reads a top level, concrete partially `Generable` type from a named property. + public func value(_ type: Value.Type = Value.self) throws -> Value + where Value: ConvertibleFromGeneratedContent { + fatalError("`GeneratedContent.value(_:)` is not implemented.") + } + + /// Reads a concrete `Generable` type from named property. + public func value(_ type: Value.Type = Value.self, + forProperty property: String) throws -> Value + where Value: ConvertibleFromGeneratedContent { + fatalError("`GeneratedContent.value(_:forProperty:)` is not implemented.") + } + + /// Reads an optional, concrete generable type from named property. + public func value(_ type: Value?.Type = Value?.self, + forProperty property: String) throws -> Value? + where Value: ConvertibleFromGeneratedContent { + fatalError("`GeneratedContent.value(_:forProperty:)` is not implemented.") + } +} + +public extension GeneratedContent { + /// A representation of the different types of content that can be stored in `GeneratedContent`. + /// + /// `Kind` represents the various types of JSON-compatible data that can be held within a + /// ``GeneratedContent`` instance, including primitive types, arrays, and structured objects. + enum Kind: Sendable { + /// Represents a null value. + case null + + /// Represents a boolean value. + /// - Parameter value: The boolean value. + case bool(Bool) + + /// Represents a numeric value. + /// - Parameter value: The numeric value as a Double. + case number(Double) + + /// Represents a string value. + /// - Parameter value: The string value. + case string(String) + + /// Represents an array of `GeneratedContent` elements. + /// - Parameter elements: An array of ``GeneratedContent`` instances. + case array([GeneratedContent]) + + /// Represents a structured object with key-value pairs. + /// - Parameters: + /// - properties: A dictionary mapping string keys to ``GeneratedContent`` values. + /// - orderedKeys: An array of keys that specifies the order of properties. + case structure(properties: [String: GeneratedContent], orderedKeys: [String]) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift new file mode 100644 index 00000000000..2443af07ef8 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift @@ -0,0 +1,16 @@ +// 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. + +/// Guides that control how values are generated. +public struct GenerationGuide {} diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift new file mode 100644 index 00000000000..32862b9d779 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift @@ -0,0 +1,163 @@ +// 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 Foundation + +/// A type that describes the properties of an object and any guides on their values. +/// +/// Generation schemas guide the output of the model to deterministically ensure the output is in +/// the desired format. +public struct GenerationSchema: Sendable { + enum Kind { + case string + case integer + case double + case boolean + case array(item: Generable.Type) + case object(name: String, description: String?, properties: [Property]) + } + + let kind: Kind + let source: String + + init(kind: Kind, source: String) { + self.kind = kind + self.source = source + } + + /// A property that belongs to a generation schema. + /// + /// Fields are named members of object types. Fields are strongly typed and have optional + /// descriptions and guides. + public struct Property: Sendable { + let name: String + let description: String? + let isOptional: Bool + let type: Generable.Type + // TODO: Store `GenerationGuide` values. + + /// Create a property that contains a generable type. + /// + /// - Parameters: + /// - name: The property's name. + /// - description: A natural language description of what content should be generated for this + /// property. + /// - type: The type this property represents. + /// - guides: A list of guides to apply to this property. + public init(name: String, description: String? = nil, type: Value.Type, + guides: [GenerationGuide] = []) where Value: Generable { + precondition(guides.isEmpty, "GenerationGuide support is not yet implemented.") + self.name = name + self.description = description + isOptional = false + self.type = Value.self + } + + /// Create an optional property that contains a generable type. + /// + /// - Parameters: + /// - name: The property's name. + /// - description: A natural language description of what content should be generated for this + /// property. + /// - type: The type this property represents. + /// - guides: A list of guides to apply to this property. + public init(name: String, description: String? = nil, type: Value?.Type, + guides: [GenerationGuide] = []) where Value: Generable { + precondition(guides.isEmpty, "GenerationGuide support is not yet implemented.") + self.name = name + self.description = description + isOptional = true + self.type = Value.self + } + } + + /// Creates a schema by providing an array of properties. + /// + /// - Parameters: + /// - type: The type this schema represents. + /// - description: A natural language description of this schema. + /// - properties: An array of properties. + public init(type: any Generable.Type, description: String? = nil, + properties: [GenerationSchema.Property]) { + let name = String(describing: type) + kind = .object(name: name, description: description, properties: properties) + source = name + } + + /// Creates a schema for a string enumeration. + /// + /// - Parameters: + /// - type: The type this schema represents. + /// - description: A natural language description of this schema. + /// - anyOf: The allowed choices. + public init(type: any Generable.Type, description: String? = nil, anyOf choices: [String]) { + fatalError("`GenerationSchema.init(type:description:anyOf:)` is not implemented.") + } + + /// Creates a schema as the union of several other types. + /// + /// - Parameters: + /// - type: The type this schema represents. + /// - description: A natural language description of this schema. + /// - anyOf: The types this schema should be a union of. + public init(type: any Generable.Type, description: String? = nil, + anyOf types: [any Generable.Type]) { + fatalError("`GenerationSchema.init(type:description:anyOf:)` is not implemented.") + } + + /// A error that occurs when there is a problem creating a generation schema. + public enum SchemaError: Error, LocalizedError { + /// The context in which the error occurred. + public struct Context: Sendable { + /// A string representation of the debug description. + /// + /// This string is not localized and is not appropriate for display to end users. + public let debugDescription: String + + public init(debugDescription: String) { + self.debugDescription = debugDescription + } + } + + /// An error that represents an attempt to construct a schema from dynamic schemas, and two or + /// more of the subschemas have the same type name. + case duplicateType(schema: String?, type: String, context: GenerationSchema.SchemaError.Context) + + /// An error that represents an attempt to construct a dynamic schema with properties that have + /// conflicting names. + case duplicateProperty( + schema: String, + property: String, + context: GenerationSchema.SchemaError.Context + ) + + /// An error that represents an attempt to construct an anyOf schema with an empty array of type + /// choices. + case emptyTypeChoices(schema: String, context: GenerationSchema.SchemaError.Context) + + /// An error that represents an attempt to construct a schema from dynamic schemas, and one of + /// those schemas references an undefined schema. + case undefinedReferences( + schema: String?, + references: [String], + context: GenerationSchema.SchemaError.Context + ) + + /// A string representation of the error description. + public var errorDescription: String? { nil } + + /// A suggestion that indicates how to handle the error. + public var recoverySuggestion: String? { nil } + } +} From 4f7b30af92713cac5a0de1f0320502faa2a54f9e Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 15:02:21 -0500 Subject: [PATCH 02/25] Add `asOpenAPISchema()` to `GenerationSchema` --- .../Public/Generable/GenerationSchema.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift index 32862b9d779..4a6e469a619 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift @@ -160,4 +160,36 @@ public struct GenerationSchema: Sendable { /// A suggestion that indicates how to handle the error. public var recoverySuggestion: String? { nil } } + + /// Returns an OpenAPI ``Schema`` equivalent of this JSON schema for testing. + public func asOpenAPISchema() -> Schema { + // TODO: Make this method internal or remove it when JSON Schema serialization is implemented. + switch kind { + case .string: + return .string() + case .integer: + return .integer() + case .double: + return .double() + case .boolean: + return .boolean() + case let .array(item: item): + return .array(items: item.generationSchema.asOpenAPISchema()) + case let .object(name: name, description: description, properties: properties): + var objectProperties = [String: Schema]() + for property in properties { + objectProperties[property.name] = property.type.generationSchema.asOpenAPISchema() + } + return .object( + properties: objectProperties, + optionalProperties: properties.compactMap { property in + guard property.isOptional else { return nil } + return property.name + }, + propertyOrdering: properties.map { $0.name }, + description: description, + title: name + ) + } + } } From 97cee24ac880de5451b9ea2c4d9c74acddd0fa79 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 15:10:22 -0500 Subject: [PATCH 03/25] Rename `GenerationSchema` to `JSONSchema` --- .../Types/Public/Generable/Generable.swift | 32 +++++++++---------- .../Public/Generable/GeneratedContent.swift | 4 +-- ...enerationSchema.swift => JSONSchema.swift} | 20 ++++++------ 3 files changed, 28 insertions(+), 28 deletions(-) rename FirebaseAI/Sources/Types/Public/Generable/{GenerationSchema.swift => JSONSchema.swift} (90%) diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index f4e658039cf..4149052ebbb 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -40,8 +40,8 @@ import Foundation /// - SeeAlso: `@Generable` macro ``Generable(description:)`` and `@Guide` macro /// ``Guide(description:)``. public protocol Generable: ConvertibleFromGeneratedContent, ConvertibleToGeneratedContent { - /// An instance of the generation schema. - static var generationSchema: GenerationSchema { get } + /// An instance of the JSON schema. + static var jsonSchema: JSONSchema { get } } extension Optional where Wrapped: Generable {} @@ -55,8 +55,8 @@ extension Optional: ConvertibleToGeneratedContent where Wrapped: ConvertibleToGe } extension Bool: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .boolean, source: "Bool") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .boolean, source: "Bool") } public init(_ content: GeneratedContent) throws { @@ -73,8 +73,8 @@ extension Bool: Generable { } extension String: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .string, source: "String") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .string, source: "String") } public init(_ content: GeneratedContent) throws { @@ -91,8 +91,8 @@ extension String: Generable { } extension Int: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .integer, source: "Int") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .integer, source: "Int") } public init(_ content: GeneratedContent) throws { @@ -112,8 +112,8 @@ extension Int: Generable { } extension Float: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .double, source: "Number") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .double, source: "Number") } public init(_ content: GeneratedContent) throws { @@ -133,8 +133,8 @@ extension Float: Generable { } extension Double: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .double, source: "Number") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .double, source: "Number") } public init(_ content: GeneratedContent) throws { @@ -154,8 +154,8 @@ extension Double: Generable { } extension Decimal: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .double, source: "Number") + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .double, source: "Number") } public init(_ content: GeneratedContent) throws { @@ -173,8 +173,8 @@ extension Decimal: Generable { } extension Array: Generable where Element: Generable { - public static var generationSchema: GenerationSchema { - GenerationSchema(kind: .array(item: Element.self), source: String(describing: self)) + public static var jsonSchema: JSONSchema { + JSONSchema(kind: .array(item: Element.self), source: String(describing: self)) } } diff --git a/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift index 122d3b8d790..b5eff276449 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift @@ -22,8 +22,8 @@ public struct GeneratedContent: Sendable, Generable { /// preserving the hierarchical structure of the data and the data's ``GenerationID`` ids. public let kind: Kind - /// An instance of the generation schema. - public static var generationSchema: GenerationSchema { + /// An instance of the JSON schema. + public static var jsonSchema: JSONSchema { // Return a schema equivalent to any legal JSON, i.e.: // { // "anyOf" : [ diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift similarity index 90% rename from FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift rename to FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift index 4a6e469a619..58a4330bfe0 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GenerationSchema.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift @@ -18,7 +18,7 @@ import Foundation /// /// Generation schemas guide the output of the model to deterministically ensure the output is in /// the desired format. -public struct GenerationSchema: Sendable { +public struct JSONSchema: Sendable { enum Kind { case string case integer @@ -36,7 +36,7 @@ public struct GenerationSchema: Sendable { self.source = source } - /// A property that belongs to a generation schema. + /// A property that belongs to a JSON schema. /// /// Fields are named members of object types. Fields are strongly typed and have optional /// descriptions and guides. @@ -89,7 +89,7 @@ public struct GenerationSchema: Sendable { /// - description: A natural language description of this schema. /// - properties: An array of properties. public init(type: any Generable.Type, description: String? = nil, - properties: [GenerationSchema.Property]) { + properties: [JSONSchema.Property]) { let name = String(describing: type) kind = .object(name: name, description: description, properties: properties) source = name @@ -116,7 +116,7 @@ public struct GenerationSchema: Sendable { fatalError("`GenerationSchema.init(type:description:anyOf:)` is not implemented.") } - /// A error that occurs when there is a problem creating a generation schema. + /// A error that occurs when there is a problem creating a JSON schema. public enum SchemaError: Error, LocalizedError { /// The context in which the error occurred. public struct Context: Sendable { @@ -132,26 +132,26 @@ public struct GenerationSchema: Sendable { /// An error that represents an attempt to construct a schema from dynamic schemas, and two or /// more of the subschemas have the same type name. - case duplicateType(schema: String?, type: String, context: GenerationSchema.SchemaError.Context) + case duplicateType(schema: String?, type: String, context: JSONSchema.SchemaError.Context) /// An error that represents an attempt to construct a dynamic schema with properties that have /// conflicting names. case duplicateProperty( schema: String, property: String, - context: GenerationSchema.SchemaError.Context + context: JSONSchema.SchemaError.Context ) /// An error that represents an attempt to construct an anyOf schema with an empty array of type /// choices. - case emptyTypeChoices(schema: String, context: GenerationSchema.SchemaError.Context) + case emptyTypeChoices(schema: String, context: JSONSchema.SchemaError.Context) /// An error that represents an attempt to construct a schema from dynamic schemas, and one of /// those schemas references an undefined schema. case undefinedReferences( schema: String?, references: [String], - context: GenerationSchema.SchemaError.Context + context: JSONSchema.SchemaError.Context ) /// A string representation of the error description. @@ -174,11 +174,11 @@ public struct GenerationSchema: Sendable { case .boolean: return .boolean() case let .array(item: item): - return .array(items: item.generationSchema.asOpenAPISchema()) + return .array(items: item.jsonSchema.asOpenAPISchema()) case let .object(name: name, description: description, properties: properties): var objectProperties = [String: Schema]() for property in properties { - objectProperties[property.name] = property.type.generationSchema.asOpenAPISchema() + objectProperties[property.name] = property.type.jsonSchema.asOpenAPISchema() } return .object( properties: objectProperties, From d8d983fedfed1e09730ebbbae7f1195bd0a1517d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 15:24:11 -0500 Subject: [PATCH 04/25] Rename `GeneratedContent` to `ModelOutput` --- ...swift => ConvertibleFromModelOutput.swift} | 16 ++-- ...t.swift => ConvertibleToModelOutput.swift} | 22 ++--- .../Types/Public/Generable/Generable.swift | 58 ++++++------- ...neratedContent.swift => ModelOutput.swift} | 84 +++++++++---------- 4 files changed, 90 insertions(+), 90 deletions(-) rename FirebaseAI/Sources/Types/Public/Generable/{ConvertibleFromGeneratedContent.swift => ConvertibleFromModelOutput.swift} (74%) rename FirebaseAI/Sources/Types/Public/Generable/{ConvertibleToGeneratedContent.swift => ConvertibleToModelOutput.swift} (66%) rename FirebaseAI/Sources/Types/Public/Generable/{GeneratedContent.swift => ModelOutput.swift} (66%) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift similarity index 74% rename from FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift rename to FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift index 4967c35963d..c8d55889973 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromGeneratedContent.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -12,30 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// A type that can be initialized from generated content. -public protocol ConvertibleFromGeneratedContent: SendableMetatype { +/// A type that can be initialized from model output. +public protocol ConvertibleFromModelOutput: SendableMetatype { /// Creates an instance from content generated by a model. /// /// Conformance to this protocol is provided by the `@Generable` macro. A manual implementation /// may be used to map values onto properties using different names. To manually initialize your - /// type from generated content, decode the values as shown below: + /// type from model output, decode the values as shown below: /// /// ```swift - /// struct Person: ConvertibleFromGeneratedContent { + /// struct Person: ConvertibleFromModelOutput { /// var name: String /// var age: Int /// - /// init(_ content: GeneratedContent) { + /// init(_ content: ModelOutput) { /// self.name = try content.value(forProperty: "firstName") /// self.age = try content.value(forProperty: "ageInYears") /// } /// } /// ``` /// - /// - Important: If your type also conforms to ``ConvertibleToGeneratedContent``, it is critical + /// - Important: If your type also conforms to ``ConvertibleToModelOutput``, it is critical /// that this implementation be symmetrical with - /// ``ConvertibleToGeneratedContent/generatedContent``. + /// ``ConvertibleToModelOutput/modelOutput``. /// /// - SeeAlso: `@Generable` macro ``Generable(description:)`` - init(_ content: GeneratedContent) throws + init(_ content: ModelOutput) throws } diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift similarity index 66% rename from FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift rename to FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift index 546c1b5c36a..af0648a8b77 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToGeneratedContent.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift @@ -12,22 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// A type that can be converted to generated content. -public protocol ConvertibleToGeneratedContent { - /// This instance represented as generated content. +/// A type that can be converted to model output. +public protocol ConvertibleToModelOutput { + /// This instance represented as model output. /// /// Conformance to this protocol is provided by the `@Generable` macro. A manual implementation - /// may be used to map values onto properties using different names. Use the generated content - /// property as shown below, to manually return a new ``GeneratedContent`` with the properties + /// may be used to map values onto properties using different names. Use the `modelOutput` + /// property as shown below, to manually return a new ``ModelOutput`` with the properties /// you specify. /// /// ```swift - /// struct Person: ConvertibleToGeneratedContent { + /// struct Person: ConvertibleToModelOutput { /// var name: String /// var age: Int /// - /// var generatedContent: GeneratedContent { - /// GeneratedContent(properties: [ + /// var modelOutput: ModelOutput { + /// ModelOutput(properties: [ /// "firstName": name, /// "ageInYears": age /// ]) @@ -35,8 +35,8 @@ public protocol ConvertibleToGeneratedContent { /// } /// ``` /// - /// - Important: If your type also conforms to ``ConvertibleFromGeneratedContent``, it is + /// - Important: If your type also conforms to ``ConvertibleFromModelOutput``, it is /// critical that this implementation be symmetrical with - /// ``ConvertibleFromGeneratedContent/init(_:)``. - var generatedContent: GeneratedContent { get } + /// ``ConvertibleFromModelOutput/init(_:)``. + var modelOutput: ModelOutput { get } } diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index 4149052ebbb..9cb2336fc5a 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -39,18 +39,18 @@ import Foundation /// ``` /// - SeeAlso: `@Generable` macro ``Generable(description:)`` and `@Guide` macro /// ``Guide(description:)``. -public protocol Generable: ConvertibleFromGeneratedContent, ConvertibleToGeneratedContent { +public protocol Generable: ConvertibleFromModelOutput, ConvertibleToModelOutput { /// An instance of the JSON schema. static var jsonSchema: JSONSchema { get } } extension Optional where Wrapped: Generable {} -extension Optional: ConvertibleToGeneratedContent where Wrapped: ConvertibleToGeneratedContent { - public var generatedContent: GeneratedContent { - guard let self else { return GeneratedContent(kind: .null) } +extension Optional: ConvertibleToModelOutput where Wrapped: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + guard let self else { return ModelOutput(kind: .null) } - return GeneratedContent(self) + return ModelOutput(self) } } @@ -59,7 +59,7 @@ extension Bool: Generable { JSONSchema(kind: .boolean, source: "Bool") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { guard case let .bool(value) = content.kind else { // TODO: Determine the correct error to throw. fatalError("Expected a boolean but found \(content.kind)") @@ -67,8 +67,8 @@ extension Bool: Generable { self = value } - public var generatedContent: GeneratedContent { - return GeneratedContent(kind: .bool(self)) + public var modelOutput: ModelOutput { + return ModelOutput(kind: .bool(self)) } } @@ -77,7 +77,7 @@ extension String: Generable { JSONSchema(kind: .string, source: "String") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { guard case let .string(value) = content.kind else { // TODO: Determine the correct error to throw. fatalError("Expected a string but found \(content.kind)") @@ -85,8 +85,8 @@ extension String: Generable { self = value } - public var generatedContent: GeneratedContent { - return GeneratedContent(kind: .string(self)) + public var modelOutput: ModelOutput { + return ModelOutput(kind: .string(self)) } } @@ -95,7 +95,7 @@ extension Int: Generable { JSONSchema(kind: .integer, source: "Int") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { // TODO: Determine the correct errors to throw. guard case let .number(value) = content.kind else { fatalError("Expected a number but found \(content.kind)") @@ -106,8 +106,8 @@ extension Int: Generable { self = integer } - public var generatedContent: GeneratedContent { - return GeneratedContent(kind: .number(Double(self))) + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(Double(self))) } } @@ -116,7 +116,7 @@ extension Float: Generable { JSONSchema(kind: .double, source: "Number") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { fatalError("Expected a number but found \(content.kind)") @@ -127,8 +127,8 @@ extension Float: Generable { self = float } - public var generatedContent: GeneratedContent { - return GeneratedContent(kind: .number(Double(self))) + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(Double(self))) } } @@ -137,7 +137,7 @@ extension Double: Generable { JSONSchema(kind: .double, source: "Number") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { fatalError("Expected a number but found \(content.kind)") @@ -148,8 +148,8 @@ extension Double: Generable { self = double } - public var generatedContent: GeneratedContent { - return GeneratedContent(kind: .number(self)) + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(self)) } } @@ -158,7 +158,7 @@ extension Decimal: Generable { JSONSchema(kind: .double, source: "Number") } - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { fatalError("Expected a number but found \(content.kind)") @@ -166,9 +166,9 @@ extension Decimal: Generable { self = Decimal(value) } - public var generatedContent: GeneratedContent { + public var modelOutput: ModelOutput { let doubleValue = (self as NSDecimalNumber).doubleValue - return GeneratedContent(kind: .number(doubleValue)) + return ModelOutput(kind: .number(doubleValue)) } } @@ -178,15 +178,15 @@ extension Array: Generable where Element: Generable { } } -extension Array: ConvertibleToGeneratedContent where Element: ConvertibleToGeneratedContent { - public var generatedContent: GeneratedContent { - let values = map { $0.generatedContent } - return GeneratedContent(kind: .array(values)) +extension Array: ConvertibleToModelOutput where Element: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + let values = map { $0.modelOutput } + return ModelOutput(kind: .array(values)) } } -extension Array: ConvertibleFromGeneratedContent where Element: ConvertibleFromGeneratedContent { - public init(_ content: GeneratedContent) throws { +extension Array: ConvertibleFromModelOutput where Element: ConvertibleFromModelOutput { + public init(_ content: ModelOutput) throws { // TODO: Determine the correct error to throw. guard case let .array(values) = content.kind else { fatalError("Expected an array but found \(content.kind)") diff --git a/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift similarity index 66% rename from FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift rename to FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index b5eff276449..8ba8ebe8da9 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GeneratedContent.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// A type that represents structured, generated content. +/// A type that represents structured model output. /// -/// Generated content may contain a single value, an array, or key-value pairs with unique keys. -public struct GeneratedContent: Sendable, Generable { - /// The kind representation of this generated content. +/// Model output may contain a single value, an array, or key-value pairs with unique keys. +public struct ModelOutput: Sendable, Generable { + /// The kind representation of this model output. /// /// This property provides access to the content in a strongly-typed enum representation, /// preserving the hierarchical structure of the data and the data's ``GenerationID`` ids. @@ -50,40 +50,40 @@ public struct GeneratedContent: Sendable, Generable { // } // ], // "description" : "Any legal JSON", - // "title" : "GeneratedContent" + // "title" : "ModelOutput" // } - fatalError("`GeneratedContent.generationSchema` is not implemented.") + fatalError("`ModelOutput.generationSchema` is not implemented.") } init(kind: Kind) { self.kind = kind } - /// Creates generated content from another value. + /// Creates model output from another value. /// /// This is used to satisfy `Generable.init(_:)`. - public init(_ content: GeneratedContent) throws { + public init(_ content: ModelOutput) throws { self = content } /// A representation of this instance. - public var generatedContent: GeneratedContent { self } + public var modelOutput: ModelOutput { self } - /// Creates generated content representing a structure with the properties you specify. + /// Creates model output representing a structure with the properties you specify. /// /// The order of properties is important. For ``Generable`` types, the order must match the order /// properties in the types `schema`. - public init(properties: KeyValuePairs) { - fatalError("`GeneratedContent.init(properties:)` is not implemented.") + public init(properties: KeyValuePairs) { + fatalError("`ModelOutput.init(properties:)` is not implemented.") } - /// Creates new generated content from the key-value pairs in the given sequence, using a + /// Creates new model output from the key-value pairs in the given sequence, using a /// combining closure to determine the value for any duplicate keys. /// /// The order of properties is important. For ``Generable`` types, the order must match the order /// properties in the types `schema`. /// - /// You use this initializer to create generated content when you have a sequence of key-value + /// You use this initializer to create model output when you have a sequence of key-value /// tuples that might have duplicate keys. As the content is built, the initializer calls the /// `combine` closure with the current and new values for any duplicate keys. Pass a closure as /// `combine` that returns the value to use in the resulting content: The closure can choose @@ -92,37 +92,37 @@ public struct GeneratedContent: Sendable, Generable { /// The following example shows how to choose the first and last values for any duplicate keys: /// /// ```swift - /// let content = GeneratedContent( + /// let content = ModelOutput( /// properties: [("name", "John"), ("name", "Jane"), ("married", true)], /// uniquingKeysWith: { (first, _) in first } /// ) - /// // GeneratedContent(["name": "John", "married": true]) + /// // ModelOutput(["name": "John", "married": true]) /// ``` /// /// - Parameters: /// - properties: A sequence of key-value pairs to use for the new content. - /// - id: A unique id associated with ``GeneratedContent``. + /// - id: A unique id associated with ``ModelOutput``. /// - combine: A closure that is called with the values to resolve any duplicates /// keys that are encountered. The closure returns the desired value for the final content. public init(properties: S, - uniquingKeysWith combine: (GeneratedContent, GeneratedContent) throws - -> some ConvertibleToGeneratedContent) rethrows where S: Sequence, S.Element == ( + uniquingKeysWith combine: (ModelOutput, ModelOutput) throws + -> some ConvertibleToModelOutput) rethrows where S: Sequence, S.Element == ( String, - any ConvertibleToGeneratedContent + any ConvertibleToModelOutput ) { var propertyNames = [String]() - var propertyMap = [String: GeneratedContent]() + var propertyMap = [String: ModelOutput]() for (key, value) in properties { if !propertyNames.contains(key) { propertyNames.append(key) - propertyMap[key] = value.generatedContent + propertyMap[key] = value.modelOutput } else { guard let existingProperty = propertyMap[key] else { // TODO: Figure out an error to throw fatalError() } - let deduplicatedProperty = try combine(existingProperty, value.generatedContent) - propertyMap[key] = deduplicatedProperty.generatedContent + let deduplicatedProperty = try combine(existingProperty, value.modelOutput) + propertyMap[key] = deduplicatedProperty.modelOutput } } @@ -130,44 +130,44 @@ public struct GeneratedContent: Sendable, Generable { } /// Creates content representing an array of elements you specify. - public init(elements: S) where S: Sequence, S.Element == any ConvertibleToGeneratedContent { - fatalError("`GeneratedContent.init(elements:)` is not implemented.") + public init(elements: S) where S: Sequence, S.Element == any ConvertibleToModelOutput { + fatalError("`ModelOutput.init(elements:)` is not implemented.") } /// Creates content that contains a single value. /// /// - Parameters: /// - value: The underlying value. - public init(_ value: some ConvertibleToGeneratedContent) { - self = value.generatedContent + public init(_ value: some ConvertibleToModelOutput) { + self = value.modelOutput } /// Reads a top level, concrete partially `Generable` type from a named property. public func value(_ type: Value.Type = Value.self) throws -> Value - where Value: ConvertibleFromGeneratedContent { - fatalError("`GeneratedContent.value(_:)` is not implemented.") + where Value: ConvertibleFromModelOutput { + fatalError("`ModelOutput.value(_:)` is not implemented.") } /// Reads a concrete `Generable` type from named property. public func value(_ type: Value.Type = Value.self, forProperty property: String) throws -> Value - where Value: ConvertibleFromGeneratedContent { - fatalError("`GeneratedContent.value(_:forProperty:)` is not implemented.") + where Value: ConvertibleFromModelOutput { + fatalError("`ModelOutput.value(_:forProperty:)` is not implemented.") } /// Reads an optional, concrete generable type from named property. public func value(_ type: Value?.Type = Value?.self, forProperty property: String) throws -> Value? - where Value: ConvertibleFromGeneratedContent { - fatalError("`GeneratedContent.value(_:forProperty:)` is not implemented.") + where Value: ConvertibleFromModelOutput { + fatalError("`ModelOutput.value(_:forProperty:)` is not implemented.") } } -public extension GeneratedContent { - /// A representation of the different types of content that can be stored in `GeneratedContent`. +public extension ModelOutput { + /// A representation of the different types of content that can be stored in `ModelOutput`. /// /// `Kind` represents the various types of JSON-compatible data that can be held within a - /// ``GeneratedContent`` instance, including primitive types, arrays, and structured objects. + /// ``ModelOutput`` instance, including primitive types, arrays, and structured objects. enum Kind: Sendable { /// Represents a null value. case null @@ -184,14 +184,14 @@ public extension GeneratedContent { /// - Parameter value: The string value. case string(String) - /// Represents an array of `GeneratedContent` elements. - /// - Parameter elements: An array of ``GeneratedContent`` instances. - case array([GeneratedContent]) + /// Represents an array of `ModelOutput` elements. + /// - Parameter elements: An array of ``ModelOutput`` instances. + case array([ModelOutput]) /// Represents a structured object with key-value pairs. /// - Parameters: - /// - properties: A dictionary mapping string keys to ``GeneratedContent`` values. + /// - properties: A dictionary mapping string keys to ``ModelOutput`` values. /// - orderedKeys: An array of keys that specifies the order of properties. - case structure(properties: [String: GeneratedContent], orderedKeys: [String]) + case structure(properties: [String: ModelOutput], orderedKeys: [String]) } } From 7184df472ecc79e97d6cde9fd5fee826d28b1b4a Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 16:01:08 -0500 Subject: [PATCH 05/25] Add `available(iOS 15.0, macOS 12.0, ..., *)` annotations --- .../Generable/ConvertibleFromModelOutput.swift | 1 + .../Public/Generable/ConvertibleToModelOutput.swift | 1 + .../Sources/Types/Public/Generable/Generable.swift | 12 ++++++++++++ .../Types/Public/Generable/GenerationGuide.swift | 1 + .../Sources/Types/Public/Generable/JSONSchema.swift | 1 + .../Sources/Types/Public/Generable/ModelOutput.swift | 1 + 6 files changed, 17 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift index c8d55889973..eb8cf938009 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -13,6 +13,7 @@ // limitations under the License. /// A type that can be initialized from model output. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleFromModelOutput: SendableMetatype { /// Creates an instance from content generated by a model. /// diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift index af0648a8b77..93367388510 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift @@ -13,6 +13,7 @@ // limitations under the License. /// A type that can be converted to model output. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleToModelOutput { /// This instance represented as model output. /// diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index 9cb2336fc5a..8769295f4da 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -39,13 +39,16 @@ import Foundation /// ``` /// - SeeAlso: `@Generable` macro ``Generable(description:)`` and `@Guide` macro /// ``Guide(description:)``. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol Generable: ConvertibleFromModelOutput, ConvertibleToModelOutput { /// An instance of the JSON schema. static var jsonSchema: JSONSchema { get } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Optional where Wrapped: Generable {} +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Optional: ConvertibleToModelOutput where Wrapped: ConvertibleToModelOutput { public var modelOutput: ModelOutput { guard let self else { return ModelOutput(kind: .null) } @@ -54,6 +57,7 @@ extension Optional: ConvertibleToModelOutput where Wrapped: ConvertibleToModelOu } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Bool: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .boolean, source: "Bool") @@ -72,6 +76,7 @@ extension Bool: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension String: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .string, source: "String") @@ -90,6 +95,7 @@ extension String: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Int: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .integer, source: "Int") @@ -111,6 +117,7 @@ extension Int: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Float: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") @@ -132,6 +139,7 @@ extension Float: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Double: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") @@ -153,6 +161,7 @@ extension Double: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Decimal: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") @@ -172,12 +181,14 @@ extension Decimal: Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Array: Generable where Element: Generable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .array(item: Element.self), source: String(describing: self)) } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Array: ConvertibleToModelOutput where Element: ConvertibleToModelOutput { public var modelOutput: ModelOutput { let values = map { $0.modelOutput } @@ -185,6 +196,7 @@ extension Array: ConvertibleToModelOutput where Element: ConvertibleToModelOutpu } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Array: ConvertibleFromModelOutput where Element: ConvertibleFromModelOutput { public init(_ content: ModelOutput) throws { // TODO: Determine the correct error to throw. diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift index 2443af07ef8..1ab6c9b3c50 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift @@ -13,4 +13,5 @@ // limitations under the License. /// Guides that control how values are generated. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GenerationGuide {} diff --git a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift index 58a4330bfe0..f48ca185ee1 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift @@ -18,6 +18,7 @@ import Foundation /// /// Generation schemas guide the output of the model to deterministically ensure the output is in /// the desired format. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct JSONSchema: Sendable { enum Kind { case string diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 8ba8ebe8da9..c2b67db01c0 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -15,6 +15,7 @@ /// A type that represents structured model output. /// /// Model output may contain a single value, an array, or key-value pairs with unique keys. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct ModelOutput: Sendable, Generable { /// The kind representation of this model output. /// From d555f0cd4d2413cb10e087311a2a1587611a91fc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 17:36:06 -0500 Subject: [PATCH 06/25] Add example output for the `Generable` macro --- .../Unit/Types/Generable/GenerableTests.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift new file mode 100644 index 00000000000..700955b6074 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -0,0 +1,66 @@ +// 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. + +// An example of the expected output from the `@FirebaseAILogic.Generable` macro. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + var properties = [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)]() + addProperty(name: "firstName", value: firstName) + addProperty(name: "middleName", value: middleName) + addProperty(name: "lastName", value: lastName) + addProperty(name: "age", value: age) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + func addProperty(name: String, value: some FirebaseAILogic.Generable) { + properties.append((name, value)) + } + func addProperty(name: String, value: (some FirebaseAILogic.Generable)?) { + if let value { + properties.append((name, value)) + } + } + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension Person: nonisolated FirebaseAILogic.Generable { + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + } +} From c435c920cd37777d1d588b00a37c450cec36546d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 24 Nov 2025 17:36:42 -0500 Subject: [PATCH 07/25] Implement `ModelOutput.value(_:forProperty:)` and add tests --- .../Types/Public/Generable/ModelOutput.swift | 21 ++++++- .../Unit/Types/Generable/GenerableTests.swift | 63 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index c2b67db01c0..30f35ec3d40 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -153,14 +153,31 @@ public struct ModelOutput: Sendable, Generable { public func value(_ type: Value.Type = Value.self, forProperty property: String) throws -> Value where Value: ConvertibleFromModelOutput { - fatalError("`ModelOutput.value(_:forProperty:)` is not implemented.") + guard case let .structure(properties, _) = kind else { + // TODO: Throw an error instead + fatalError("Attempting to access a property on a non-object ModelOutput.") + } + guard let value = properties[property] else { + // TODO: Throw an error instead + fatalError("Property '\(property)' not found in model output.") + } + + return try Value(value) } /// Reads an optional, concrete generable type from named property. public func value(_ type: Value?.Type = Value?.self, forProperty property: String) throws -> Value? where Value: ConvertibleFromModelOutput { - fatalError("`ModelOutput.value(_:forProperty:)` is not implemented.") + guard case let .structure(properties, _) = kind else { + // TODO: Throw an error instead + fatalError("Attempting to access a property on a non-object ModelOutput.") + } + guard let value = properties[property] else { + return nil + } + + return try Value(value) } } diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 700955b6074..2996f1d3ab7 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -12,6 +12,69 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAILogic +import Testing + +struct GenerableTests { + @Test + func initializeGenerableTypeFromModelOutput() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.firstName == "John") + #expect(person.lastName == "Doe") + #expect(person.age == 40) + } + + @Test + func convertGenerableTypeToModelOutput() throws { + let person = Person(firstName: "Jane", middleName: "Marie", lastName: "Smith", age: 32) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + Issue.record("Model output is not a structure.") + return + } + let firstNameProperty = try #require(properties["firstName"]) + guard case let .string(firstName) = firstNameProperty.kind else { + Issue.record("The 'firstName' property is not a string: \(firstNameProperty.kind)") + return + } + #expect(firstName == person.firstName) + #expect(try modelOutput.value(forProperty: "firstName") == person.firstName) + let middleNameProperty = try #require(properties["middleName"]) + guard case let .string(middleName) = middleNameProperty.kind else { + Issue.record("The 'middleName' property is not a string: \(middleNameProperty.kind)") + return + } + #expect(middleName == person.middleName) + #expect(try modelOutput.value(forProperty: "middleName") == person.middleName) + let lastNameProperty = try #require(properties["lastName"]) + guard case let .string(lastName) = lastNameProperty.kind else { + Issue.record("The 'lastName' property is not a string: \(lastNameProperty.kind)") + return + } + #expect(lastName == person.lastName) + #expect(try modelOutput.value(forProperty: "lastName") == person.lastName) + let ageProperty = try #require(properties["age"]) + guard case let .number(age) = ageProperty.kind else { + Issue.record("The 'age' property is not a number: \(ageProperty.kind)") + return + } + #expect(Int(age) == person.age) + #expect(try modelOutput.value(forProperty: "age") == person.age) + // TODO: Implement `ModelOutput.value(_:)` and uncomment + // #expect(try modelOutput.value() == person) + #expect(orderedKeys == ["firstName", "middleName", "lastName", "age"]) + } +} + // An example of the expected output from the `@FirebaseAILogic.Generable` macro. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct Person: Equatable { From 61449924df4774aa6e906719d5cd0f74ac659d0d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 12:22:48 -0500 Subject: [PATCH 08/25] Add `GenerativeModel.GenerationError` and throw `decodingFailure` --- .../Types/Public/Generable/Generable.swift | 36 ++++++++++------ .../GenerativeModel+GenerationError.swift | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index 8769295f4da..325cd5c4794 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -65,8 +65,7 @@ extension Bool: Generable { public init(_ content: ModelOutput) throws { guard case let .bool(value) = content.kind else { - // TODO: Determine the correct error to throw. - fatalError("Expected a boolean but found \(content.kind)") + throw Self.decodingFailure(content) } self = value } @@ -84,8 +83,7 @@ extension String: Generable { public init(_ content: ModelOutput) throws { guard case let .string(value) = content.kind else { - // TODO: Determine the correct error to throw. - fatalError("Expected a string but found \(content.kind)") + throw Self.decodingFailure(content) } self = value } @@ -102,10 +100,10 @@ extension Int: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine the correct errors to throw. guard case let .number(value) = content.kind else { - fatalError("Expected a number but found \(content.kind)") + throw Self.decodingFailure(content) } + // TODO: Determine the correct error to throw. guard let integer = Int(exactly: value) else { fatalError("Expected an integer but found \(value)") } @@ -124,10 +122,10 @@ extension Float: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { - fatalError("Expected a number but found \(content.kind)") + throw Self.decodingFailure(content) } + // TODO: Determine the correct error to throw. guard let float = Float(exactly: value) else { fatalError("Expected a float but found \(value)") } @@ -146,10 +144,10 @@ extension Double: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { - fatalError("Expected a number but found \(content.kind)") + throw Self.decodingFailure(content) } + // TODO: Determine the correct error to throw. guard let double = Double(exactly: value) else { fatalError("Expected a double but found \(value)") } @@ -168,9 +166,8 @@ extension Decimal: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine the correct error to throw. guard case let .number(value) = content.kind else { - fatalError("Expected a number but found \(content.kind)") + throw Self.decodingFailure(content) } self = Decimal(value) } @@ -199,10 +196,21 @@ extension Array: ConvertibleToModelOutput where Element: ConvertibleToModelOutpu @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Array: ConvertibleFromModelOutput where Element: ConvertibleFromModelOutput { public init(_ content: ModelOutput) throws { - // TODO: Determine the correct error to throw. guard case let .array(values) = content.kind else { - fatalError("Expected an array but found \(content.kind)") + throw Self.decodingFailure(content) } self = try values.map { try Element($0) } } } + +private extension ConvertibleFromModelOutput { + /// Helper method to create ``GenerativeModel/GenerationError/decodingFailure(_:)`` instances. + static func decodingFailure(_ content: ModelOutput) -> GenerativeModel.GenerationError { + return GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(content.self) does not contain \(Self.self). + Content: \(content) + """) + ) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift new file mode 100644 index 00000000000..d505b46a2ef --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift @@ -0,0 +1,43 @@ +// 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 Foundation + +public extension GenerativeModel { + /// An error that may occur while generating a response. + enum GenerationError: Error { + /// The context in which the error occurred. + public struct Context: Sendable { + /// A debug description to help developers diagnose issues during development. + /// + /// This string is not localized and is not appropriate for display to end users. + public let debugDescription: String + + /// Creates a context. + /// + /// - Parameters: + /// - debugDescription: The debug description to help developers diagnose issues during + /// development. + public init(debugDescription: String) { + self.debugDescription = debugDescription + } + } + + /// An error that indicates the session failed to deserialize a valid generable type from model + /// output. + /// + /// This can happen if generation was terminated early. + case decodingFailure(GenerativeModel.GenerationError.Context) + } +} From 5541a002e31336bbf8d5b74712e03c2ce24c924f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 12:37:05 -0500 Subject: [PATCH 09/25] Fix Xcode 16 build errors --- .../Types/Public/Generable/ConvertibleFromModelOutput.swift | 2 ++ FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift index eb8cf938009..ad6cddf2a82 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + /// A type that can be initialized from model output. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleFromModelOutput: SendableMetatype { diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 30f35ec3d40..36d3626dde5 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -181,6 +181,7 @@ public struct ModelOutput: Sendable, Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension ModelOutput { /// A representation of the different types of content that can be stored in `ModelOutput`. /// From 4cec2b30d30f2aa79c89789d023cd50f364bf036 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 15:01:34 -0500 Subject: [PATCH 10/25] More build fixes --- FirebaseAI/Sources/Types/Public/Generable/Generable.swift | 1 + .../Generable/GenerativeModel+GenerationError.swift | 1 + .../Tests/Unit/Types/Generable/GenerableTests.swift | 8 ++++---- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index 325cd5c4794..19ebf57fcbf 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -203,6 +203,7 @@ extension Array: ConvertibleFromModelOutput where Element: ConvertibleFromModelO } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) private extension ConvertibleFromModelOutput { /// Helper method to create ``GenerativeModel/GenerationError/decodingFailure(_:)`` instances. static func decodingFailure(_ content: ModelOutput) -> GenerativeModel.GenerationError { diff --git a/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift index d505b46a2ef..421378b0fca 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift @@ -14,6 +14,7 @@ import Foundation +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension GenerativeModel { /// An error that may occur while generating a response. enum GenerationError: Error { diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 2996f1d3ab7..e33d1d72526 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -16,8 +16,8 @@ import FirebaseAILogic import Testing struct GenerableTests { - @Test - func initializeGenerableTypeFromModelOutput() throws { + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + @Test func initializeGenerableTypeFromModelOutput() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("lastName", "Doe"), ("age", 40)] let modelOutput = ModelOutput( @@ -31,8 +31,8 @@ struct GenerableTests { #expect(person.age == 40) } - @Test - func convertGenerableTypeToModelOutput() throws { + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + @Test func convertGenerableTypeToModelOutput() throws { let person = Person(firstName: "Jane", middleName: "Marie", lastName: "Smith", age: 32) let modelOutput = person.modelOutput From a1aa0b054c3be294c00facf2bd77dcb95e51bc0a Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 15:17:39 -0500 Subject: [PATCH 11/25] Add `SendableMetatype` typealias for older Xcode versions --- .../Types/Public/Generable/JSONSchema.swift | 2 +- .../Sources/Types/Public/SendableMetatype.swift | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 FirebaseAI/Sources/Types/Public/SendableMetatype.swift diff --git a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift index f48ca185ee1..15538214291 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift @@ -20,7 +20,7 @@ import Foundation /// the desired format. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct JSONSchema: Sendable { - enum Kind { + enum Kind: Sendable { case string case integer case double diff --git a/FirebaseAI/Sources/Types/Public/SendableMetatype.swift b/FirebaseAI/Sources/Types/Public/SendableMetatype.swift new file mode 100644 index 00000000000..46d8f4d1968 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/SendableMetatype.swift @@ -0,0 +1,17 @@ +// 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. + +#if compiler(<6.2) + public typealias SendableMetatype = Any +#endif From f687ab17a22df1113c5e39c1c8fad4547fd2f302 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 17:26:04 -0500 Subject: [PATCH 12/25] Another Xcode 16 workaround --- .../Unit/Types/Generable/GenerableTests.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index e33d1d72526..365403709ea 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -118,12 +118,24 @@ struct Person: Equatable { } } -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Person: nonisolated FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { - firstName = try content.value(forProperty: "firstName") - middleName = try content.value(forProperty: "middleName") - lastName = try content.value(forProperty: "lastName") - age = try content.value(forProperty: "age") +#if compiler(>=6.2) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Person: nonisolated FirebaseAILogic.Generable { + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + } } -} +#else + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Person: FirebaseAILogic.Generable { + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + } + } +#endif From a219b40a4b4c2689ba3c176b8c18429f5e38f6bc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 25 Nov 2025 18:32:52 -0500 Subject: [PATCH 13/25] Coalesce type conversion failures --- .../Types/Public/Generable/Generable.swift | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index 19ebf57fcbf..cdd1fcc348f 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -100,13 +100,9 @@ extension Int: Generable { } public init(_ content: ModelOutput) throws { - guard case let .number(value) = content.kind else { + guard case let .number(value) = content.kind, let integer = Int(exactly: value) else { throw Self.decodingFailure(content) } - // TODO: Determine the correct error to throw. - guard let integer = Int(exactly: value) else { - fatalError("Expected an integer but found \(value)") - } self = integer } @@ -122,13 +118,10 @@ extension Float: Generable { } public init(_ content: ModelOutput) throws { - guard case let .number(value) = content.kind else { + // TODO: Determine if we need to use `exactly: ` or be more lenient. + guard case let .number(value) = content.kind, let float = Float(exactly: value) else { throw Self.decodingFailure(content) } - // TODO: Determine the correct error to throw. - guard let float = Float(exactly: value) else { - fatalError("Expected a float but found \(value)") - } self = float } @@ -144,13 +137,10 @@ extension Double: Generable { } public init(_ content: ModelOutput) throws { - guard case let .number(value) = content.kind else { + // TODO: Determine if we need to use `exactly: ` or be more lenient. + guard case let .number(value) = content.kind, let double = Double(exactly: value) else { throw Self.decodingFailure(content) } - // TODO: Determine the correct error to throw. - guard let double = Double(exactly: value) else { - fatalError("Expected a double but found \(value)") - } self = double } From 5cafb190a009288609bfd8993b631270b2dbc727 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Wed, 26 Nov 2025 11:16:13 -0800 Subject: [PATCH 14/25] Implement TODOs and add tests (#15535) --- .../Types/Public/Generable/ModelOutput.swift | 65 ++- .../Unit/Types/Generable/GenerableTests.swift | 401 +++++++++++++++--- 2 files changed, 409 insertions(+), 57 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 36d3626dde5..da3c09cc452 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -16,7 +16,7 @@ /// /// Model output may contain a single value, an array, or key-value pairs with unique keys. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public struct ModelOutput: Sendable, Generable { +public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { /// The kind representation of this model output. /// /// This property provides access to the content in a strongly-typed enum representation, @@ -70,6 +70,10 @@ public struct ModelOutput: Sendable, Generable { /// A representation of this instance. public var modelOutput: ModelOutput { self } + public var debugDescription: String { + return kind.debugDescription + } + /// Creates model output representing a structure with the properties you specify. /// /// The order of properties is important. For ``Generable`` types, the order must match the order @@ -146,7 +150,7 @@ public struct ModelOutput: Sendable, Generable { /// Reads a top level, concrete partially `Generable` type from a named property. public func value(_ type: Value.Type = Value.self) throws -> Value where Value: ConvertibleFromModelOutput { - fatalError("`ModelOutput.value(_:)` is not implemented.") + return try Value(self) } /// Reads a concrete `Generable` type from named property. @@ -154,12 +158,10 @@ public struct ModelOutput: Sendable, Generable { forProperty property: String) throws -> Value where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - // TODO: Throw an error instead - fatalError("Attempting to access a property on a non-object ModelOutput.") + throw DecodingError.notAStructure } guard let value = properties[property] else { - // TODO: Throw an error instead - fatalError("Property '\(property)' not found in model output.") + throw DecodingError.missingProperty(name: property) } return try Value(value) @@ -170,8 +172,7 @@ public struct ModelOutput: Sendable, Generable { forProperty property: String) throws -> Value? where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - // TODO: Throw an error instead - fatalError("Attempting to access a property on a non-object ModelOutput.") + throw DecodingError.notAStructure } guard let value = properties[property] else { return nil @@ -183,11 +184,35 @@ public struct ModelOutput: Sendable, Generable { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension ModelOutput { + /// An error that occurs when decoding a value from `ModelOutput`. + enum DecodingError: Error, CustomDebugStringConvertible { + /// A required property was not found in the `ModelOutput`. + case missingProperty(name: String) + + /// A property was accessed on a `ModelOutput` that is not a structure. + case notAStructure + + /// The context for a decoding error. + public struct Context: Sendable { + /// A description of the error. + public let debugDescription: String + } + + public var debugDescription: String { + switch self { + case let .missingProperty(name): + return "Missing property: \(name)" + case .notAStructure: + return "Not a structure" + } + } + } + /// A representation of the different types of content that can be stored in `ModelOutput`. /// /// `Kind` represents the various types of JSON-compatible data that can be held within a /// ``ModelOutput`` instance, including primitive types, arrays, and structured objects. - enum Kind: Sendable { + enum Kind: Sendable, CustomDebugStringConvertible { /// Represents a null value. case null @@ -212,5 +237,27 @@ public extension ModelOutput { /// - properties: A dictionary mapping string keys to ``ModelOutput`` values. /// - orderedKeys: An array of keys that specifies the order of properties. case structure(properties: [String: ModelOutput], orderedKeys: [String]) + + public var debugDescription: String { + switch self { + case .null: + return "null" + case let .bool(value): + return String(describing: value) + case let .number(value): + return String(describing: value) + case let .string(value): + return #""\#(value)""# + case let .array(elements): + let descriptions = elements.map { $0.debugDescription } + return "[\(descriptions.joined(separator: ", "))]" + case let .structure(properties, orderedKeys): + let descriptions = orderedKeys.compactMap { key -> String? in + guard let value = properties[key] else { return nil } + return #""\#(key)": \#(value.debugDescription)"# + } + return "{\(descriptions.joined(separator: ", "))}" + } + } } } diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 365403709ea..a4f863b294c 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -12,14 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAILogic +@testable import FirebaseAILogic import Testing struct GenerableTests { - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - @Test func initializeGenerableTypeFromModelOutput() throws { + @Test + func initializeGenerableTypeFromModelOutput() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) let properties: [(String, any ConvertibleToModelOutput)] = - [("firstName", "John"), ("lastName", "Doe"), ("age", 40)] + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) @@ -29,11 +34,142 @@ struct GenerableTests { #expect(person.firstName == "John") #expect(person.lastName == "Doe") #expect(person.age == 40) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") } - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - @Test func convertGenerableTypeToModelOutput() throws { - let person = Person(firstName: "Jane", middleName: "Marie", lastName: "Smith", age: 32) + @Test + func initializeGenerableWithMissingProperty() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("age", 40)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let ModelOutput.DecodingError.missingProperty(name) { + #expect(name == "lastName") + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @Test + func initializeGenerableFromNonStructure() throws { + let modelOutput = ModelOutput("not a structure") + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch ModelOutput.DecodingError.notAStructure { + // Expected error + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @Test + func initializeGenerableWithTypeMismatch() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("\"forty\" does not contain Int")) + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @Test + func initializeGenerableWithLossyNumericConversion() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40.5)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("40.5 does not contain Int.")) + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @Test + func initializeGenerableWithExtraProperties() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) + let properties: [(String, any ConvertibleToModelOutput)] = + [ + ("firstName", "John"), + ("lastName", "Doe"), + ("age", 40), + ("address", addressModelOutput), + ("country", "USA"), + ] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.firstName == "John") + #expect(person.lastName == "Doe") + #expect(person.age == 40) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") + } + + @Test + func initializeGenerableWithMissingOptionalProperty() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.firstName == "John") + #expect(person.lastName == "Doe") + #expect(person.age == 40) + #expect(person.middleName == nil) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") + } + + @Test + func convertGenerableTypeToModelOutput() throws { + let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") + let person = Person( + firstName: "Jane", + middleName: "Marie", + lastName: "Smith", + age: 32, + address: address + ) let modelOutput = person.modelOutput @@ -69,56 +205,106 @@ struct GenerableTests { } #expect(Int(age) == person.age) #expect(try modelOutput.value(forProperty: "age") == person.age) - // TODO: Implement `ModelOutput.value(_:)` and uncomment - // #expect(try modelOutput.value() == person) - #expect(orderedKeys == ["firstName", "middleName", "lastName", "age"]) + let addressProperty: Address = try modelOutput.value(forProperty: "address") + #expect(addressProperty == person.address) + #expect(try modelOutput.value() == person) + #expect(orderedKeys == ["firstName", "middleName", "lastName", "age", "address"]) } -} -// An example of the expected output from the `@FirebaseAILogic.Generable` macro. -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -struct Person: Equatable { - let firstName: String - let middleName: String? - let lastName: String - let age: Int - - nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), - FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), - ] + @Test + func convertGenerableWithNilOptionalPropertyToModelOutput() throws { + let address = Address(street: "789 Pine Ln", city: "Nowhere", zipCode: "00000") + let person = Person( + firstName: "Jane", + middleName: nil, + lastName: "Smith", + age: 32, + address: address ) - } - nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - var properties = [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)]() - addProperty(name: "firstName", value: firstName) - addProperty(name: "middleName", value: middleName) - addProperty(name: "lastName", value: lastName) - addProperty(name: "age", value: age) - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) - func addProperty(name: String, value: some FirebaseAILogic.Generable) { - properties.append((name, value)) + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + Issue.record("Model output is not a structure.") + return } - func addProperty(name: String, value: (some FirebaseAILogic.Generable)?) { - if let value { - properties.append((name, value)) - } + + #expect(properties["middleName"] == nil) + #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) + } + + @Test + func testPersonJSONSchema() throws { + let schema = Person.jsonSchema + guard case let .object(_, _, properties) = schema.kind else { + Issue.record("Schema kind is not an object.") + return } + #expect(properties.count == 5) + + let firstName = try #require(properties.first { $0.name == "firstName" }) + #expect(ObjectIdentifier(firstName.type) == ObjectIdentifier(String.self)) + #expect(firstName.isOptional == false) + + let middleName = try #require(properties.first { $0.name == "middleName" }) + #expect(ObjectIdentifier(middleName.type) == ObjectIdentifier(String.self)) + #expect(middleName.isOptional == true) + + let lastName = try #require(properties.first { $0.name == "lastName" }) + #expect(ObjectIdentifier(lastName.type) == ObjectIdentifier(String.self)) + #expect(lastName.isOptional == false) + + let age = try #require(properties.first { $0.name == "age" }) + #expect(ObjectIdentifier(age.type) == ObjectIdentifier(Int.self)) + #expect(age.isOptional == false) + + let address = try #require(properties.first { $0.name == "address" }) + #expect(ObjectIdentifier(address.type) == ObjectIdentifier(Address.self)) + #expect(address.isOptional == false) } } #if compiler(>=6.2) + // An example of the expected output from the `@FirebaseAILogic.Generable` macro. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + let address: Address + + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) + } + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } + } + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Person: nonisolated FirebaseAILogic.Generable { nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { @@ -126,16 +312,135 @@ struct Person: Equatable { middleName = try content.value(forProperty: "middleName") lastName = try content.value(forProperty: "lastName") age = try content.value(forProperty: "age") + address = try content.value(forProperty: "address") } } #else + // An example of the expected output from the `@FirebaseAILogic.Generable` macro. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + let address: Address + + static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), + ] + ) + } + + var modelOutput: FirebaseAILogic.ModelOutput { + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) + } + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } + } + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Person: FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + init(_ content: FirebaseAILogic.ModelOutput) throws { firstName = try content.value(forProperty: "firstName") middleName = try content.value(forProperty: "middleName") lastName = try content.value(forProperty: "lastName") age = try content.value(forProperty: "age") + address = try content.value(forProperty: "address") + } + } +#endif + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct Address: Equatable { + let street: String + let city: String + let zipCode: String +} + +#if compiler(>=6.2) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Address: nonisolated FirebaseAILogic.Generable { + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), + ] + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } + + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") + } + } +#else + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Address: FirebaseAILogic.Generable { + static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } + + var modelOutput: FirebaseAILogic.ModelOutput { + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), + ] + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } + + init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") } } #endif From e0febcac249d19c96ab364c2ab12b7b39f027f68 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 26 Nov 2025 14:17:51 -0500 Subject: [PATCH 15/25] Decode `Float` and `Double` more leniently --- .../Sources/Types/Public/Generable/Generable.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift index cdd1fcc348f..5cd73a52dd8 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -118,11 +118,10 @@ extension Float: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine if we need to use `exactly: ` or be more lenient. - guard case let .number(value) = content.kind, let float = Float(exactly: value) else { + guard case let .number(value) = content.kind else { throw Self.decodingFailure(content) } - self = float + self = Float(value) } public var modelOutput: ModelOutput { @@ -137,11 +136,10 @@ extension Double: Generable { } public init(_ content: ModelOutput) throws { - // TODO: Determine if we need to use `exactly: ` or be more lenient. - guard case let .number(value) = content.kind, let double = Double(exactly: value) else { + guard case let .number(value) = content.kind else { throw Self.decodingFailure(content) } - self = double + self = value } public var modelOutput: ModelOutput { From 52b4e17a4f43619a6de14420b1d349411ef41381 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 26 Nov 2025 14:18:55 -0500 Subject: [PATCH 16/25] Update error messages in `ModelOutput.value(...)` methods # Conflicts: # FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift --- .../Types/Public/Generable/ModelOutput.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index da3c09cc452..22a9d1f93de 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -158,10 +158,20 @@ public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { forProperty property: String) throws -> Value where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - throw DecodingError.notAStructure + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(Self.self) does not contain an object. + Content: \(self.kind) + """) + ) } guard let value = properties[property] else { - throw DecodingError.missingProperty(name: property) + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(Self.self) does not contain a property '\(property)'. + Content: \(self) + """) + ) } return try Value(value) From 4c113d093e84ea04a9279525ddb2a958cd75abae Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 26 Nov 2025 15:07:17 -0500 Subject: [PATCH 17/25] Replace `ModelOutput.DecodingError` with `GenerativeModel.GenerationError` --- .../Types/Public/Generable/ModelOutput.swift | 33 +-- .../Unit/Types/Generable/GenerableTests.swift | 259 +++++++----------- 2 files changed, 104 insertions(+), 188 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 22a9d1f93de..40be1ba638e 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -161,7 +161,7 @@ public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { throw GenerativeModel.GenerationError.decodingFailure( GenerativeModel.GenerationError.Context(debugDescription: """ \(Self.self) does not contain an object. - Content: \(self.kind) + Content: \(kind) """) ) } @@ -182,7 +182,12 @@ public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { forProperty property: String) throws -> Value? where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - throw DecodingError.notAStructure + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(Self.self) does not contain an object. + Content: \(kind) + """) + ) } guard let value = properties[property] else { return nil @@ -194,30 +199,6 @@ public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension ModelOutput { - /// An error that occurs when decoding a value from `ModelOutput`. - enum DecodingError: Error, CustomDebugStringConvertible { - /// A required property was not found in the `ModelOutput`. - case missingProperty(name: String) - - /// A property was accessed on a `ModelOutput` that is not a structure. - case notAStructure - - /// The context for a decoding error. - public struct Context: Sendable { - /// A description of the error. - public let debugDescription: String - } - - public var debugDescription: String { - switch self { - case let .missingProperty(name): - return "Missing property: \(name)" - case .notAStructure: - return "Not a structure" - } - } - } - /// A representation of the different types of content that can be stored in `ModelOutput`. /// /// `Kind` represents the various types of JSON-compatible data that can be held within a diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index a4f863b294c..d8768ad7f25 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -40,53 +40,56 @@ struct GenerableTests { } @Test - func initializeGenerableWithMissingProperty() throws { + func initializeGenerableWithMissingPropertyThrows() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("age", 40)] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let ModelOutput.DecodingError.missingProperty(name) { - #expect(name == "lastName") - } catch { + let error = try #require(throws: GenerativeModel.GenerationError.self) { + try Person(modelOutput) + } + + guard case let .decodingFailure(context) = error else { Issue.record("Threw an unexpected error: \(error)") + return } + #expect(context.debugDescription.contains("lastName")) } @Test - func initializeGenerableFromNonStructure() throws { + func initializeGenerableFromNonStructureThrows() throws { let modelOutput = ModelOutput("not a structure") - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch ModelOutput.DecodingError.notAStructure { - // Expected error - } catch { + let error = try #require(throws: GenerativeModel.GenerationError.self) { + try Person(modelOutput) + } + + guard case let .decodingFailure(context) = error else { Issue.record("Threw an unexpected error: \(error)") + return } + #expect(context.debugDescription.contains("does not contain an object")) } @Test - func initializeGenerableWithTypeMismatch() throws { + func initializeGenerableWithTypeMismatchThrows() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("\"forty\" does not contain Int")) - } catch { + let error = try #require(throws: GenerativeModel.GenerationError.self) { + try Person(modelOutput) + } + + guard case let .decodingFailure(context) = error else { Issue.record("Threw an unexpected error: \(error)") + return } + #expect(context.debugDescription.contains("\"forty\" does not contain Int")) } @Test @@ -97,14 +100,15 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("40.5 does not contain Int.")) - } catch { + let error = try #require(throws: GenerativeModel.GenerationError.self) { + try Person(modelOutput) + } + + guard case let .decodingFailure(context) = error else { Issue.record("Threw an unexpected error: \(error)") + return } + #expect(context.debugDescription.contains("40.5 does not contain Int.")) } @Test @@ -228,7 +232,6 @@ struct GenerableTests { Issue.record("Model output is not a structure.") return } - #expect(properties["middleName"] == nil) #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) } @@ -236,75 +239,72 @@ struct GenerableTests { @Test func testPersonJSONSchema() throws { let schema = Person.jsonSchema + guard case let .object(_, _, properties) = schema.kind else { Issue.record("Schema kind is not an object.") return } - #expect(properties.count == 5) + #expect(properties.count == 5) let firstName = try #require(properties.first { $0.name == "firstName" }) #expect(ObjectIdentifier(firstName.type) == ObjectIdentifier(String.self)) #expect(firstName.isOptional == false) - let middleName = try #require(properties.first { $0.name == "middleName" }) #expect(ObjectIdentifier(middleName.type) == ObjectIdentifier(String.self)) #expect(middleName.isOptional == true) - let lastName = try #require(properties.first { $0.name == "lastName" }) #expect(ObjectIdentifier(lastName.type) == ObjectIdentifier(String.self)) #expect(lastName.isOptional == false) - let age = try #require(properties.first { $0.name == "age" }) #expect(ObjectIdentifier(age.type) == ObjectIdentifier(Int.self)) #expect(age.isOptional == false) - let address = try #require(properties.first { $0.name == "address" }) #expect(ObjectIdentifier(address.type) == ObjectIdentifier(Address.self)) #expect(address.isOptional == false) } } -#if compiler(>=6.2) - // An example of the expected output from the `@FirebaseAILogic.Generable` macro. - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - struct Person: Equatable { - let firstName: String - let middleName: String? - let lastName: String - let age: Int - let address: Address - - nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), - FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), - FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), - ] - ) - } +// An example of the expected output from the `@FirebaseAILogic.Generable` macro. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + let address: Address + + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), + ] + ) + } - nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] - properties.append(("firstName", firstName)) - if let middleName { - properties.append(("middleName", middleName)) - } - properties.append(("lastName", lastName)) - properties.append(("age", age)) - properties.append(("address", address)) - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) } + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) } +} +#if compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Person: nonisolated FirebaseAILogic.Generable { nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { @@ -316,46 +316,6 @@ struct GenerableTests { } } #else - // An example of the expected output from the `@FirebaseAILogic.Generable` macro. - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - struct Person: Equatable { - let firstName: String - let middleName: String? - let lastName: String - let age: Int - let address: Address - - static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), - FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), - FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), - ] - ) - } - - var modelOutput: FirebaseAILogic.ModelOutput { - var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] - properties.append(("firstName", firstName)) - if let middleName { - properties.append(("middleName", middleName)) - } - properties.append(("lastName", lastName)) - properties.append(("age", age)) - properties.append(("address", address)) - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) - } - } - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Person: FirebaseAILogic.Generable { init(_ content: FirebaseAILogic.ModelOutput) throws { @@ -366,43 +326,43 @@ struct GenerableTests { address = try content.value(forProperty: "address") } } -#endif +#endif // compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct Address: Equatable { let street: String let city: String let zipCode: String + + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), + ] + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } } #if compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Address: nonisolated FirebaseAILogic.Generable { - nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), - ] - ) - } - - nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ - ("street", street), - ("city", city), - ("zipCode", zipCode), - ] - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) - } - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { street = try content.value(forProperty: "street") city = try content.value(forProperty: "city") @@ -412,35 +372,10 @@ struct Address: Equatable { #else @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Address: FirebaseAILogic.Generable { - static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), - ] - ) - } - - var modelOutput: FirebaseAILogic.ModelOutput { - let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ - ("street", street), - ("city", city), - ("zipCode", zipCode), - ] - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) - } - init(_ content: FirebaseAILogic.ModelOutput) throws { street = try content.value(forProperty: "street") city = try content.value(forProperty: "city") zipCode = try content.value(forProperty: "zipCode") } } -#endif +#endif // compiler(>=6.2) From 65d786004cd1bd0d4350a927505a6aeebac50a09 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 10:45:17 -0500 Subject: [PATCH 18/25] Fix `GenerableTests` on Xcode 16.2 --- .../Unit/Types/Generable/GenerableTests.swift | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index d8768ad7f25..aa2a3188fd3 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -47,30 +47,28 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - let error = try #require(throws: GenerativeModel.GenerationError.self) { - try Person(modelOutput) - } - - guard case let .decodingFailure(context) = error else { + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("lastName")) + } catch { Issue.record("Threw an unexpected error: \(error)") - return } - #expect(context.debugDescription.contains("lastName")) } @Test func initializeGenerableFromNonStructureThrows() throws { let modelOutput = ModelOutput("not a structure") - let error = try #require(throws: GenerativeModel.GenerationError.self) { - try Person(modelOutput) - } - - guard case let .decodingFailure(context) = error else { + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("does not contain an object")) + } catch { Issue.record("Threw an unexpected error: \(error)") - return } - #expect(context.debugDescription.contains("does not contain an object")) } @Test @@ -81,15 +79,14 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - let error = try #require(throws: GenerativeModel.GenerationError.self) { - try Person(modelOutput) - } - - guard case let .decodingFailure(context) = error else { + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("\"forty\" does not contain Int")) + } catch { Issue.record("Threw an unexpected error: \(error)") - return } - #expect(context.debugDescription.contains("\"forty\" does not contain Int")) } @Test @@ -100,15 +97,14 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - let error = try #require(throws: GenerativeModel.GenerationError.self) { - try Person(modelOutput) - } - - guard case let .decodingFailure(context) = error else { + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("40.5 does not contain Int.")) + } catch { Issue.record("Threw an unexpected error: \(error)") - return } - #expect(context.debugDescription.contains("40.5 does not contain Int.")) } @Test From 33c2a1e289b658d0ba399ee08b1e989abb3fd645 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 11:00:44 -0500 Subject: [PATCH 19/25] Add `available(iOS 15.0, macOS 12.0, ..., *)` to `GenerableTests` --- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index aa2a3188fd3..6ee0f90f26f 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -15,6 +15,7 @@ @testable import FirebaseAILogic import Testing +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct GenerableTests { @Test func initializeGenerableTypeFromModelOutput() throws { From ad396d5feb4edc9dbccf03cf3fa69a15e4c6eb0d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 11:24:49 -0500 Subject: [PATCH 20/25] Try forcing Swift 6 mode on macOS 14 / Xcode 16.2 --- .github/workflows/firebaseai.yml | 4 +++- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/firebaseai.yml b/.github/workflows/firebaseai.yml index 1f3cde4a26a..6aa1adc666f 100644 --- a/.github/workflows/firebaseai.yml +++ b/.github/workflows/firebaseai.yml @@ -78,7 +78,9 @@ jobs: with: product: ${{ matrix.product }} supports_swift6: true - setup_command: scripts/update_vertexai_responses.sh + setup_command: | + scripts/update_vertexai_responses.sh; \ + sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '6.0'/" ${{ matrix.product }}.podspec quickstart: if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 6ee0f90f26f..aa2a3188fd3 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -15,7 +15,6 @@ @testable import FirebaseAILogic import Testing -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct GenerableTests { @Test func initializeGenerableTypeFromModelOutput() throws { From 99229572fae29957ac87dd40ddf24cd80882e98c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 17:02:26 -0500 Subject: [PATCH 21/25] Rewrite `GenerableTests` from Swift Testing to XCTest --- .../Unit/Types/Generable/GenerableTests.swift | 206 +++++++++--------- 1 file changed, 98 insertions(+), 108 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index aa2a3188fd3..97a38c20937 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -13,11 +13,10 @@ // limitations under the License. @testable import FirebaseAILogic -import Testing +import XCTest -struct GenerableTests { - @Test - func initializeGenerableTypeFromModelOutput() throws { +final class GenerableTests: XCTestCase { + func testInitializeGenerableTypeFromModelOutput() throws { let addressProperties: [(String, any ConvertibleToModelOutput)] = [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] let addressModelOutput = ModelOutput( @@ -31,84 +30,79 @@ struct GenerableTests { let person = try Person(modelOutput) - #expect(person.firstName == "John") - #expect(person.lastName == "Doe") - #expect(person.age == 40) - #expect(person.address.street == "123 Main St") - #expect(person.address.city == "Anytown") - #expect(person.address.zipCode == "12345") + XCTAssertEqual(person.firstName, "John") + XCTAssertEqual(person.lastName, "Doe") + XCTAssertEqual(person.age, 40) + XCTAssertEqual(person.address.street, "123 Main St") + XCTAssertEqual(person.address.city, "Anytown") + XCTAssertEqual(person.address.zipCode, "12345") } - @Test - func initializeGenerableWithMissingPropertyThrows() throws { + func testInitializeGenerableWithMissingPropertyThrows() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("age", 40)] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("lastName")) - } catch { - Issue.record("Threw an unexpected error: \(error)") + XCTAssertThrowsError(try Person(modelOutput)) { error in + guard let error = error as? GenerativeModel.GenerationError, + case let .decodingFailure(context) = error else { + XCTFail("Threw an unexpected error: \(error)") + return + } + XCTAssertContains(context.debugDescription, "lastName") } } - @Test - func initializeGenerableFromNonStructureThrows() throws { + func testInitializeGenerableFromNonStructureThrows() throws { let modelOutput = ModelOutput("not a structure") - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("does not contain an object")) - } catch { - Issue.record("Threw an unexpected error: \(error)") + XCTAssertThrowsError(try Person(modelOutput)) { error in + guard let error = error as? GenerativeModel.GenerationError, + case let .decodingFailure(context) = error else { + XCTFail("Threw an unexpected error: \(error)") + return + } + XCTAssertContains(context.debugDescription, "does not contain an object") } } - @Test - func initializeGenerableWithTypeMismatchThrows() throws { + func testInitializeGenerableWithTypeMismatchThrows() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("\"forty\" does not contain Int")) - } catch { - Issue.record("Threw an unexpected error: \(error)") + XCTAssertThrowsError(try Person(modelOutput)) { error in + guard let error = error as? GenerativeModel.GenerationError, + case let .decodingFailure(context) = error else { + XCTFail("Threw an unexpected error: \(error)") + return + } + XCTAssertContains(context.debugDescription, "\"forty\" does not contain Int") } } - @Test - func initializeGenerableWithLossyNumericConversion() throws { + func testInitializeGenerableWithLossyNumericConversion() throws { let properties: [(String, any ConvertibleToModelOutput)] = [("firstName", "John"), ("lastName", "Doe"), ("age", 40.5)] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - do { - _ = try Person(modelOutput) - Issue.record("Did not throw an error.") - } catch let GenerativeModel.GenerationError.decodingFailure(context) { - #expect(context.debugDescription.contains("40.5 does not contain Int.")) - } catch { - Issue.record("Threw an unexpected error: \(error)") + XCTAssertThrowsError(try Person(modelOutput)) { error in + guard let error = error as? GenerativeModel.GenerationError, + case let .decodingFailure(context) = error else { + XCTFail("Threw an unexpected error: \(error)") + return + } + XCTAssertContains(context.debugDescription, "40.5 does not contain Int.") } } - @Test - func initializeGenerableWithExtraProperties() throws { + func testInitializeGenerableWithExtraProperties() throws { let addressProperties: [(String, any ConvertibleToModelOutput)] = [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] let addressModelOutput = ModelOutput( @@ -128,16 +122,15 @@ struct GenerableTests { let person = try Person(modelOutput) - #expect(person.firstName == "John") - #expect(person.lastName == "Doe") - #expect(person.age == 40) - #expect(person.address.street == "123 Main St") - #expect(person.address.city == "Anytown") - #expect(person.address.zipCode == "12345") + XCTAssertEqual(person.firstName, "John") + XCTAssertEqual(person.lastName, "Doe") + XCTAssertEqual(person.age, 40) + XCTAssertEqual(person.address.street, "123 Main St") + XCTAssertEqual(person.address.city, "Anytown") + XCTAssertEqual(person.address.zipCode, "12345") } - @Test - func initializeGenerableWithMissingOptionalProperty() throws { + func testInitializeGenerableWithMissingOptionalProperty() throws { let addressProperties: [(String, any ConvertibleToModelOutput)] = [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] let addressModelOutput = ModelOutput( @@ -151,17 +144,16 @@ struct GenerableTests { let person = try Person(modelOutput) - #expect(person.firstName == "John") - #expect(person.lastName == "Doe") - #expect(person.age == 40) - #expect(person.middleName == nil) - #expect(person.address.street == "123 Main St") - #expect(person.address.city == "Anytown") - #expect(person.address.zipCode == "12345") + XCTAssertEqual(person.firstName, "John") + XCTAssertEqual(person.lastName, "Doe") + XCTAssertEqual(person.age, 40) + XCTAssertNil(person.middleName) + XCTAssertEqual(person.address.street, "123 Main St") + XCTAssertEqual(person.address.city, "Anytown") + XCTAssertEqual(person.address.zipCode, "12345") } - @Test - func convertGenerableTypeToModelOutput() throws { + func testConvertGenerableTypeToModelOutput() throws { let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") let person = Person( firstName: "Jane", @@ -174,45 +166,44 @@ struct GenerableTests { let modelOutput = person.modelOutput guard case let .structure(properties, orderedKeys) = modelOutput.kind else { - Issue.record("Model output is not a structure.") + XCTFail("Model output is not a structure.") return } - let firstNameProperty = try #require(properties["firstName"]) + let firstNameProperty = try XCTUnwrap(properties["firstName"]) guard case let .string(firstName) = firstNameProperty.kind else { - Issue.record("The 'firstName' property is not a string: \(firstNameProperty.kind)") + XCTFail("The 'firstName' property is not a string: \(firstNameProperty.kind)") return } - #expect(firstName == person.firstName) - #expect(try modelOutput.value(forProperty: "firstName") == person.firstName) - let middleNameProperty = try #require(properties["middleName"]) + XCTAssertEqual(firstName, person.firstName) + XCTAssertEqual(try modelOutput.value(forProperty: "firstName"), person.firstName) + let middleNameProperty = try XCTUnwrap(properties["middleName"]) guard case let .string(middleName) = middleNameProperty.kind else { - Issue.record("The 'middleName' property is not a string: \(middleNameProperty.kind)") + XCTFail("The 'middleName' property is not a string: \(middleNameProperty.kind)") return } - #expect(middleName == person.middleName) - #expect(try modelOutput.value(forProperty: "middleName") == person.middleName) - let lastNameProperty = try #require(properties["lastName"]) + XCTAssertEqual(middleName, person.middleName) + XCTAssertEqual(try modelOutput.value(forProperty: "middleName"), person.middleName) + let lastNameProperty = try XCTUnwrap(properties["lastName"]) guard case let .string(lastName) = lastNameProperty.kind else { - Issue.record("The 'lastName' property is not a string: \(lastNameProperty.kind)") + XCTFail("The 'lastName' property is not a string: \(lastNameProperty.kind)") return } - #expect(lastName == person.lastName) - #expect(try modelOutput.value(forProperty: "lastName") == person.lastName) - let ageProperty = try #require(properties["age"]) + XCTAssertEqual(lastName, person.lastName) + XCTAssertEqual(try modelOutput.value(forProperty: "lastName"), person.lastName) + let ageProperty = try XCTUnwrap(properties["age"]) guard case let .number(age) = ageProperty.kind else { - Issue.record("The 'age' property is not a number: \(ageProperty.kind)") + XCTFail("The 'age' property is not a number: \(ageProperty.kind)") return } - #expect(Int(age) == person.age) - #expect(try modelOutput.value(forProperty: "age") == person.age) + XCTAssertEqual(Int(age), person.age) + XCTAssertEqual(try modelOutput.value(forProperty: "age"), person.age) let addressProperty: Address = try modelOutput.value(forProperty: "address") - #expect(addressProperty == person.address) - #expect(try modelOutput.value() == person) - #expect(orderedKeys == ["firstName", "middleName", "lastName", "age", "address"]) + XCTAssertEqual(addressProperty, person.address) + XCTAssertEqual(try modelOutput.value(), person) + XCTAssertEqual(orderedKeys, ["firstName", "middleName", "lastName", "age", "address"]) } - @Test - func convertGenerableWithNilOptionalPropertyToModelOutput() throws { + func testConvertGenerableWithNilOptionalPropertyToModelOutput() throws { let address = Address(street: "789 Pine Ln", city: "Nowhere", zipCode: "00000") let person = Person( firstName: "Jane", @@ -225,38 +216,37 @@ struct GenerableTests { let modelOutput = person.modelOutput guard case let .structure(properties, orderedKeys) = modelOutput.kind else { - Issue.record("Model output is not a structure.") + XCTFail("Model output is not a structure.") return } - #expect(properties["middleName"] == nil) - #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) + XCTAssertNil(properties["middleName"]) + XCTAssertEqual(orderedKeys, ["firstName", "lastName", "age", "address"]) } - @Test func testPersonJSONSchema() throws { let schema = Person.jsonSchema guard case let .object(_, _, properties) = schema.kind else { - Issue.record("Schema kind is not an object.") + XCTFail("Schema kind is not an object.") return } - #expect(properties.count == 5) - let firstName = try #require(properties.first { $0.name == "firstName" }) - #expect(ObjectIdentifier(firstName.type) == ObjectIdentifier(String.self)) - #expect(firstName.isOptional == false) - let middleName = try #require(properties.first { $0.name == "middleName" }) - #expect(ObjectIdentifier(middleName.type) == ObjectIdentifier(String.self)) - #expect(middleName.isOptional == true) - let lastName = try #require(properties.first { $0.name == "lastName" }) - #expect(ObjectIdentifier(lastName.type) == ObjectIdentifier(String.self)) - #expect(lastName.isOptional == false) - let age = try #require(properties.first { $0.name == "age" }) - #expect(ObjectIdentifier(age.type) == ObjectIdentifier(Int.self)) - #expect(age.isOptional == false) - let address = try #require(properties.first { $0.name == "address" }) - #expect(ObjectIdentifier(address.type) == ObjectIdentifier(Address.self)) - #expect(address.isOptional == false) + XCTAssertEqual(properties.count, 5) + let firstName = try XCTUnwrap(properties.first { $0.name == "firstName" }) + XCTAssert(firstName.type == String.self) + XCTAssertFalse(firstName.isOptional) + let middleName = try XCTUnwrap(properties.first { $0.name == "middleName" }) + XCTAssert(middleName.type == String.self) + XCTAssertTrue(middleName.isOptional) + let lastName = try XCTUnwrap(properties.first { $0.name == "lastName" }) + XCTAssert(lastName.type == String.self) + XCTAssertFalse(lastName.isOptional) + let age = try XCTUnwrap(properties.first { $0.name == "age" }) + XCTAssert(age.type == Int.self) + XCTAssertFalse(age.isOptional) + let address = try XCTUnwrap(properties.first { $0.name == "address" }) + XCTAssert(address.type == Address.self) + XCTAssertFalse(address.isOptional) } } From bfb5bf43ab2436937366bee74ca4a188f7d545c2 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 17:03:42 -0500 Subject: [PATCH 22/25] Add `available(iOS 15.0, macOS 12.0, ..., *)` annotation --- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 97a38c20937..dd9ffb9cf99 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -15,6 +15,7 @@ @testable import FirebaseAILogic import XCTest +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerableTests: XCTestCase { func testInitializeGenerableTypeFromModelOutput() throws { let addressProperties: [(String, any ConvertibleToModelOutput)] = From 7f8bd29887ad564bd1d885456fc5b0351ae4ac36 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 10 Dec 2025 11:45:44 -0500 Subject: [PATCH 23/25] Add converters between FirebaseAI and AFM types --- FirebaseAI/Sources/GenerationConfig.swift | 10 +-- FirebaseAI/Sources/GenerativeModel.swift | 64 +++++++++++++++++++ .../GenerationSchema+Gemini.swift | 49 ++++++++++++++ .../ConvertibleFromModelOutput.swift | 18 ++++++ .../Generable/ConvertibleToModelOutput.swift | 50 +++++++++++++++ ...enerable.swift => FirebaseGenerable.swift} | 18 +++--- .../Types/Public/Generable/JSONSchema.swift | 17 ++--- .../Types/Public/Generable/ModelOutput.swift | 56 +++++++++++++++- .../Unit/Types/Generable/GenerableTests.swift | 4 +- 9 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift rename FirebaseAI/Sources/Types/Public/Generable/{Generable.swift => FirebaseGenerable.swift} (93%) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index fe2b6963e22..079a5dc083e 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -28,7 +28,7 @@ public struct GenerationConfig: Sendable { let topK: Int? /// The number of response variations to return. - let candidateCount: Int? + var candidateCount: Int? /// Maximum number of tokens that can be generated in the response. let maxOutputTokens: Int? @@ -43,18 +43,18 @@ public struct GenerationConfig: Sendable { let stopSequences: [String]? /// Output response MIME type of the generated candidate text. - let responseMIMEType: String? + var responseMIMEType: String? /// Output schema of the generated candidate text. - let responseSchema: Schema? + var responseSchema: Schema? /// Output schema of the generated response in [JSON Schema](https://json-schema.org/) format. /// /// If set, `responseSchema` must be omitted and `responseMIMEType` is required. - let responseJSONSchema: JSONObject? + var responseJSONSchema: JSONObject? /// Supported modalities of the response. - let responseModalities: [ResponseModality]? + var responseModalities: [ResponseModality]? /// Configuration for controlling the "thinking" behavior of compatible Gemini models. let thinkingConfig: ThinkingConfig? diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index cadb2728c70..7b138ee3126 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -144,6 +144,12 @@ public final class GenerativeModel: Sendable { /// - Throws: A ``GenerateContentError`` if the request failed. public func generateContent(_ content: [ModelContent]) async throws -> GenerateContentResponse { + return try await generateContent(content, generationConfig: generationConfig) + } + + public func generateContent(_ content: [ModelContent], + generationConfig: GenerationConfig?) async throws + -> GenerateContentResponse { try content.throwIfError() let response: GenerateContentResponse let generateContentRequest = GenerateContentRequest( @@ -357,6 +363,50 @@ public final class GenerativeModel: Sendable { return try await generativeAIService.loadRequest(request: countTokensRequest) } + /// Produces a generable object as a response to a prompt. + /// + /// - Parameters: + /// - prompt: A prompt for the model to respond to. + /// - type: A type to produce as the response. + /// - Returns: ``GeneratedContent`` containing the fields and values defined in the schema. + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public final func generateObject(_ type: Content.Type = Content.self, + parts: any PartsRepresentable...) async throws + -> Response + where Content: FoundationModels.Generable { + let jsonSchema = try type.generationSchema.asGeminiJSONSchema() + + let generationConfig = { + var generationConfig = self.generationConfig ?? GenerationConfig() + if generationConfig.candidateCount != nil { + generationConfig.candidateCount = nil + } + generationConfig.responseMIMEType = "application/json" + if generationConfig.responseSchema != nil { + generationConfig.responseSchema = nil + } + generationConfig.responseJSONSchema = jsonSchema + if generationConfig.responseModalities != nil { + generationConfig.responseModalities = nil + } + + return generationConfig + }() + + let response = try await generateContent( + [ModelContent(parts: parts)], + generationConfig: generationConfig + ) + + let generatedContent = try GeneratedContent(json: response.text ?? "") + let content = try Content(generatedContent) + let rawContent = try ModelOutput(generatedContent) + + return Response(content: content, rawContent: rawContent) + } + /// Returns a `GenerateContentError` (for public consumption) from an internal error. /// /// If `error` is already a `GenerateContentError` the error is returned unchanged. @@ -366,4 +416,18 @@ public final class GenerativeModel: Sendable { } return GenerateContentError.internalError(underlying: error) } + + /// A structure that stores the output of a response call. + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public struct Response { + /// The response content. + public let content: Content + + /// The raw response content. + /// + /// When `Content` is `ModelOutput`, this is the same as `content`. + public let rawContent: ModelOutput + } } diff --git a/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift b/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift new file mode 100644 index 00000000000..165d9b51650 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift @@ -0,0 +1,49 @@ +// 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. + +#if canImport(FoundationModels) + import Foundation + import FoundationModels + + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension GenerationSchema { + func asGeminiJSONSchema() throws -> JSONObject { + let jsonData = try JSONEncoder().encode(self) + var jsonSchema = try JSONDecoder().decode(JSONObject.self, from: jsonData) + updatePropertyOrdering(&jsonSchema) + + return jsonSchema + } + } + + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + fileprivate func updatePropertyOrdering(_ schema: inout JSONObject) { + guard let propertyOrdering = schema.removeValue(forKey: "x-order") else { + return + } + guard case let .array(values) = propertyOrdering else { + return + } + guard values.allSatisfy({ + guard case .string = $0 else { return false } + return true + }) else { + return + } + + schema["propertyOrdering"] = propertyOrdering + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift index ad6cddf2a82..8657e1b5c6e 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -13,6 +13,9 @@ // limitations under the License. import Foundation +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) /// A type that can be initialized from model output. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -42,3 +45,18 @@ public protocol ConvertibleFromModelOutput: SendableMetatype { /// - SeeAlso: `@Generable` macro ``Generable(description:)`` init(_ content: ModelOutput) throws } + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension FoundationModels.GeneratedContent: ConvertibleFromModelOutput {} + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension FoundationModels.ConvertibleFromGeneratedContent + where Self: ConvertibleFromModelOutput { + init(_ content: ModelOutput) throws { + try self.init(content.generatedContent) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift index 93367388510..3ece81b25bf 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + /// A type that can be converted to model output. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleToModelOutput { @@ -41,3 +45,49 @@ public protocol ConvertibleToModelOutput { /// ``ConvertibleFromModelOutput/init(_:)``. var modelOutput: ModelOutput { get } } + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension FoundationModels.GeneratedContent: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + switch kind { + case .null: + return ModelOutput(kind: .null) + case let .bool(value): + return ModelOutput(kind: .bool(value)) + case let .number(value): + return ModelOutput(kind: .number(value)) + case let .string(value): + return ModelOutput(kind: .string(value)) + case let .array(values): + return ModelOutput(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return ModelOutput(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(kind)") + } + } +} + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension FoundationModels.ConvertibleToGeneratedContent + where Self: ConvertibleToModelOutput { + var generatedContent: GeneratedContent { + modelOutput.generatedContent + } +} + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension ConvertibleToModelOutput + where Self: FoundationModels.ConvertibleToGeneratedContent { + var modelOutput: ModelOutput { + generatedContent.modelOutput + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift b/FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.swift similarity index 93% rename from FirebaseAI/Sources/Types/Public/Generable/Generable.swift rename to FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.swift index 5cd73a52dd8..815ed18cb0d 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/Generable.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.swift @@ -40,13 +40,13 @@ import Foundation /// - SeeAlso: `@Generable` macro ``Generable(description:)`` and `@Guide` macro /// ``Guide(description:)``. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public protocol Generable: ConvertibleFromModelOutput, ConvertibleToModelOutput { +public protocol FirebaseGenerable: ConvertibleFromModelOutput, ConvertibleToModelOutput { /// An instance of the JSON schema. static var jsonSchema: JSONSchema { get } } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Optional where Wrapped: Generable {} +extension Optional where Wrapped: FirebaseGenerable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Optional: ConvertibleToModelOutput where Wrapped: ConvertibleToModelOutput { @@ -58,7 +58,7 @@ extension Optional: ConvertibleToModelOutput where Wrapped: ConvertibleToModelOu } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Bool: Generable { +extension Bool: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .boolean, source: "Bool") } @@ -76,7 +76,7 @@ extension Bool: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension String: Generable { +extension String: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .string, source: "String") } @@ -94,7 +94,7 @@ extension String: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Int: Generable { +extension Int: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .integer, source: "Int") } @@ -112,7 +112,7 @@ extension Int: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Float: Generable { +extension Float: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") } @@ -130,7 +130,7 @@ extension Float: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Double: Generable { +extension Double: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") } @@ -148,7 +148,7 @@ extension Double: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Decimal: Generable { +extension Decimal: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .double, source: "Number") } @@ -167,7 +167,7 @@ extension Decimal: Generable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Array: Generable where Element: Generable { +extension Array: FirebaseGenerable where Element: FirebaseGenerable { public static var jsonSchema: JSONSchema { JSONSchema(kind: .array(item: Element.self), source: String(describing: self)) } diff --git a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift index 15538214291..6c9ceb031e5 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift @@ -25,7 +25,7 @@ public struct JSONSchema: Sendable { case integer case double case boolean - case array(item: Generable.Type) + case array(item: FirebaseGenerable.Type) case object(name: String, description: String?, properties: [Property]) } @@ -45,7 +45,7 @@ public struct JSONSchema: Sendable { let name: String let description: String? let isOptional: Bool - let type: Generable.Type + let type: FirebaseGenerable.Type // TODO: Store `GenerationGuide` values. /// Create a property that contains a generable type. @@ -57,7 +57,7 @@ public struct JSONSchema: Sendable { /// - type: The type this property represents. /// - guides: A list of guides to apply to this property. public init(name: String, description: String? = nil, type: Value.Type, - guides: [GenerationGuide] = []) where Value: Generable { + guides: [GenerationGuide] = []) where Value: FirebaseGenerable { precondition(guides.isEmpty, "GenerationGuide support is not yet implemented.") self.name = name self.description = description @@ -74,7 +74,7 @@ public struct JSONSchema: Sendable { /// - type: The type this property represents. /// - guides: A list of guides to apply to this property. public init(name: String, description: String? = nil, type: Value?.Type, - guides: [GenerationGuide] = []) where Value: Generable { + guides: [GenerationGuide] = []) where Value: FirebaseGenerable { precondition(guides.isEmpty, "GenerationGuide support is not yet implemented.") self.name = name self.description = description @@ -89,7 +89,7 @@ public struct JSONSchema: Sendable { /// - type: The type this schema represents. /// - description: A natural language description of this schema. /// - properties: An array of properties. - public init(type: any Generable.Type, description: String? = nil, + public init(type: any FirebaseGenerable.Type, description: String? = nil, properties: [JSONSchema.Property]) { let name = String(describing: type) kind = .object(name: name, description: description, properties: properties) @@ -102,7 +102,8 @@ public struct JSONSchema: Sendable { /// - type: The type this schema represents. /// - description: A natural language description of this schema. /// - anyOf: The allowed choices. - public init(type: any Generable.Type, description: String? = nil, anyOf choices: [String]) { + public init(type: any FirebaseGenerable.Type, description: String? = nil, + anyOf choices: [String]) { fatalError("`GenerationSchema.init(type:description:anyOf:)` is not implemented.") } @@ -112,8 +113,8 @@ public struct JSONSchema: Sendable { /// - type: The type this schema represents. /// - description: A natural language description of this schema. /// - anyOf: The types this schema should be a union of. - public init(type: any Generable.Type, description: String? = nil, - anyOf types: [any Generable.Type]) { + public init(type: any FirebaseGenerable.Type, description: String? = nil, + anyOf types: [any FirebaseGenerable.Type]) { fatalError("`GenerationSchema.init(type:description:anyOf:)` is not implemented.") } diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 40be1ba638e..11b8c8d0987 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -12,11 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + /// A type that represents structured model output. /// /// Model output may contain a single value, an array, or key-value pairs with unique keys. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { +public struct ModelOutput: Sendable, FirebaseGenerable, CustomDebugStringConvertible { /// The kind representation of this model output. /// /// This property provides access to the content in a strongly-typed enum representation, @@ -252,3 +256,53 @@ public extension ModelOutput { } } } + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension ModelOutput: FoundationModels.ConvertibleFromGeneratedContent { + public init(_ content: GeneratedContent) throws { + switch content.kind { + case .null: + self.init(kind: .null) + case let .bool(value): + self.init(kind: .bool(value)) + case let .number(value): + self.init(kind: .number(value)) + case let .string(value): + self.init(kind: .string(value)) + case let .array(values): + self.init(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + self.init(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(content.kind)") + } + } +} + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension ModelOutput: FoundationModels.ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + switch modelOutput.kind { + case .null: + return GeneratedContent(kind: .null) + case let .bool(value): + return GeneratedContent(kind: .bool(value)) + case let .number(value): + return GeneratedContent(kind: .number(value)) + case let .string(value): + return GeneratedContent(kind: .string(value)) + case let .array(values): + return GeneratedContent(kind: .array(values.map { $0.generatedContent })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return GeneratedContent(kind: .structure( + properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys + )) + } + } +} diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index dd9ffb9cf99..ce0f5ddc930 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -293,7 +293,7 @@ struct Person: Equatable { #if compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Person: nonisolated FirebaseAILogic.Generable { + extension Person: nonisolated FirebaseAILogic.FirebaseGenerable { nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { firstName = try content.value(forProperty: "firstName") middleName = try content.value(forProperty: "middleName") @@ -349,7 +349,7 @@ struct Address: Equatable { #if compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Address: nonisolated FirebaseAILogic.Generable { + extension Address: nonisolated FirebaseAILogic.FirebaseGenerable { nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { street = try content.value(forProperty: "street") city = try content.value(forProperty: "city") From 839bbca991d2e895ae60dcc76bbd20396b75862a Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 10 Dec 2025 11:57:34 -0500 Subject: [PATCH 24/25] Add `FoundationModels` import in `GenerativeModel` --- FirebaseAI/Sources/GenerativeModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 7b138ee3126..34c44356a62 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -15,6 +15,9 @@ import FirebaseAppCheckInterop import FirebaseAuthInterop import Foundation +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) /// A type that represents a remote multimodal model (like Gemini), with the ability to generate /// content based on various input types. From 06e19aae25abe780ec2590ed69583a05b6dafe98 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 10 Dec 2025 14:15:13 -0500 Subject: [PATCH 25/25] Fix build on Xcode 15 --- FirebaseAI/Sources/GenerativeModel.swift | 84 +++++++-------- .../ConvertibleFromModelOutput.swift | 28 ++--- .../Generable/ConvertibleToModelOutput.swift | 84 ++++++++------- .../Types/Public/Generable/ModelOutput.swift | 92 ++++++++-------- .../Unit/Types/Generable/GenerableTests.swift | 102 +++++++++--------- 5 files changed, 203 insertions(+), 187 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 34c44356a62..68263e3d288 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -366,49 +366,51 @@ public final class GenerativeModel: Sendable { return try await generativeAIService.loadRequest(request: countTokensRequest) } - /// Produces a generable object as a response to a prompt. - /// - /// - Parameters: - /// - prompt: A prompt for the model to respond to. - /// - type: A type to produce as the response. - /// - Returns: ``GeneratedContent`` containing the fields and values defined in the schema. - @available(iOS 26.0, macOS 26.0, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public final func generateObject(_ type: Content.Type = Content.self, - parts: any PartsRepresentable...) async throws - -> Response - where Content: FoundationModels.Generable { - let jsonSchema = try type.generationSchema.asGeminiJSONSchema() - - let generationConfig = { - var generationConfig = self.generationConfig ?? GenerationConfig() - if generationConfig.candidateCount != nil { - generationConfig.candidateCount = nil - } - generationConfig.responseMIMEType = "application/json" - if generationConfig.responseSchema != nil { - generationConfig.responseSchema = nil - } - generationConfig.responseJSONSchema = jsonSchema - if generationConfig.responseModalities != nil { - generationConfig.responseModalities = nil - } + #if canImport(FoundationModels) + /// Produces a generable object as a response to a prompt. + /// + /// - Parameters: + /// - prompt: A prompt for the model to respond to. + /// - type: A type to produce as the response. + /// - Returns: ``GeneratedContent`` containing the fields and values defined in the schema. + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public final func generateObject(_ type: Content.Type = Content.self, + parts: any PartsRepresentable...) async throws + -> Response + where Content: FoundationModels.Generable { + let jsonSchema = try type.generationSchema.asGeminiJSONSchema() + + let generationConfig = { + var generationConfig = self.generationConfig ?? GenerationConfig() + if generationConfig.candidateCount != nil { + generationConfig.candidateCount = nil + } + generationConfig.responseMIMEType = "application/json" + if generationConfig.responseSchema != nil { + generationConfig.responseSchema = nil + } + generationConfig.responseJSONSchema = jsonSchema + if generationConfig.responseModalities != nil { + generationConfig.responseModalities = nil + } - return generationConfig - }() + return generationConfig + }() - let response = try await generateContent( - [ModelContent(parts: parts)], - generationConfig: generationConfig - ) + let response = try await generateContent( + [ModelContent(parts: parts)], + generationConfig: generationConfig + ) - let generatedContent = try GeneratedContent(json: response.text ?? "") - let content = try Content(generatedContent) - let rawContent = try ModelOutput(generatedContent) + let generatedContent = try GeneratedContent(json: response.text ?? "") + let content = try Content(generatedContent) + let rawContent = try ModelOutput(generatedContent) - return Response(content: content, rawContent: rawContent) - } + return Response(content: content, rawContent: rawContent) + } + #endif // canImport(FoundationModels) /// Returns a `GenerateContentError` (for public consumption) from an internal error. /// @@ -421,9 +423,7 @@ public final class GenerativeModel: Sendable { } /// A structure that stores the output of a response call. - @available(iOS 26.0, macOS 26.0, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct Response { /// The response content. public let content: Content diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift index 8657e1b5c6e..edd74095132 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -46,17 +46,21 @@ public protocol ConvertibleFromModelOutput: SendableMetatype { init(_ content: ModelOutput) throws } -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension FoundationModels.GeneratedContent: ConvertibleFromModelOutput {} +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FoundationModels.GeneratedContent: ConvertibleFromModelOutput {} +#endif // canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public extension FoundationModels.ConvertibleFromGeneratedContent - where Self: ConvertibleFromModelOutput { - init(_ content: ModelOutput) throws { - try self.init(content.generatedContent) +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension FoundationModels.ConvertibleFromGeneratedContent + where Self: ConvertibleFromModelOutput { + init(_ content: ModelOutput) throws { + try self.init(content.generatedContent) + } } -} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift index 3ece81b25bf..6779eb29a7a 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift @@ -46,48 +46,54 @@ public protocol ConvertibleToModelOutput { var modelOutput: ModelOutput { get } } -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension FoundationModels.GeneratedContent: ConvertibleToModelOutput { - public var modelOutput: ModelOutput { - switch kind { - case .null: - return ModelOutput(kind: .null) - case let .bool(value): - return ModelOutput(kind: .bool(value)) - case let .number(value): - return ModelOutput(kind: .number(value)) - case let .string(value): - return ModelOutput(kind: .string(value)) - case let .array(values): - return ModelOutput(kind: .array(values.map { $0.modelOutput })) - case let .structure(properties: properties, orderedKeys: orderedKeys): - return ModelOutput(kind: .structure( - properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys - )) - @unknown default: - fatalError("Unsupported GeneratedContent kind: \(kind)") +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FoundationModels.GeneratedContent: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + switch kind { + case .null: + return ModelOutput(kind: .null) + case let .bool(value): + return ModelOutput(kind: .bool(value)) + case let .number(value): + return ModelOutput(kind: .number(value)) + case let .string(value): + return ModelOutput(kind: .string(value)) + case let .array(values): + return ModelOutput(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return ModelOutput(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(kind)") + } } } -} +#endif // canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public extension FoundationModels.ConvertibleToGeneratedContent - where Self: ConvertibleToModelOutput { - var generatedContent: GeneratedContent { - modelOutput.generatedContent +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension FoundationModels.ConvertibleToGeneratedContent + where Self: ConvertibleToModelOutput { + var generatedContent: GeneratedContent { + modelOutput.generatedContent + } } -} +#endif // canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public extension ConvertibleToModelOutput - where Self: FoundationModels.ConvertibleToGeneratedContent { - var modelOutput: ModelOutput { - generatedContent.modelOutput +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension ConvertibleToModelOutput + where Self: FoundationModels.ConvertibleToGeneratedContent { + var modelOutput: ModelOutput { + generatedContent.modelOutput + } } -} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 11b8c8d0987..ce6786f51f8 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -257,52 +257,56 @@ public extension ModelOutput { } } -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension ModelOutput: FoundationModels.ConvertibleFromGeneratedContent { - public init(_ content: GeneratedContent) throws { - switch content.kind { - case .null: - self.init(kind: .null) - case let .bool(value): - self.init(kind: .bool(value)) - case let .number(value): - self.init(kind: .number(value)) - case let .string(value): - self.init(kind: .string(value)) - case let .array(values): - self.init(kind: .array(values.map { $0.modelOutput })) - case let .structure(properties: properties, orderedKeys: orderedKeys): - self.init(kind: .structure( - properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys - )) - @unknown default: - fatalError("Unsupported GeneratedContent kind: \(content.kind)") +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension ModelOutput: FoundationModels.ConvertibleFromGeneratedContent { + public init(_ content: GeneratedContent) throws { + switch content.kind { + case .null: + self.init(kind: .null) + case let .bool(value): + self.init(kind: .bool(value)) + case let .number(value): + self.init(kind: .number(value)) + case let .string(value): + self.init(kind: .string(value)) + case let .array(values): + self.init(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + self.init(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(content.kind)") + } } } -} +#endif // canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension ModelOutput: FoundationModels.ConvertibleToGeneratedContent { - public var generatedContent: GeneratedContent { - switch modelOutput.kind { - case .null: - return GeneratedContent(kind: .null) - case let .bool(value): - return GeneratedContent(kind: .bool(value)) - case let .number(value): - return GeneratedContent(kind: .number(value)) - case let .string(value): - return GeneratedContent(kind: .string(value)) - case let .array(values): - return GeneratedContent(kind: .array(values.map { $0.generatedContent })) - case let .structure(properties: properties, orderedKeys: orderedKeys): - return GeneratedContent(kind: .structure( - properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys - )) +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension ModelOutput: FoundationModels.ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + switch modelOutput.kind { + case .null: + return GeneratedContent(kind: .null) + case let .bool(value): + return GeneratedContent(kind: .bool(value)) + case let .number(value): + return GeneratedContent(kind: .number(value)) + case let .string(value): + return GeneratedContent(kind: .string(value)) + case let .array(values): + return GeneratedContent(kind: .array(values.map { $0.generatedContent })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return GeneratedContent(kind: .structure( + properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys + )) + } } } -} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index ce0f5ddc930..51e25f1add8 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -154,55 +154,57 @@ final class GenerableTests: XCTestCase { XCTAssertEqual(person.address.zipCode, "12345") } - func testConvertGenerableTypeToModelOutput() throws { - let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") - let person = Person( - firstName: "Jane", - middleName: "Marie", - lastName: "Smith", - age: 32, - address: address - ) - - let modelOutput = person.modelOutput - - guard case let .structure(properties, orderedKeys) = modelOutput.kind else { - XCTFail("Model output is not a structure.") - return - } - let firstNameProperty = try XCTUnwrap(properties["firstName"]) - guard case let .string(firstName) = firstNameProperty.kind else { - XCTFail("The 'firstName' property is not a string: \(firstNameProperty.kind)") - return - } - XCTAssertEqual(firstName, person.firstName) - XCTAssertEqual(try modelOutput.value(forProperty: "firstName"), person.firstName) - let middleNameProperty = try XCTUnwrap(properties["middleName"]) - guard case let .string(middleName) = middleNameProperty.kind else { - XCTFail("The 'middleName' property is not a string: \(middleNameProperty.kind)") - return - } - XCTAssertEqual(middleName, person.middleName) - XCTAssertEqual(try modelOutput.value(forProperty: "middleName"), person.middleName) - let lastNameProperty = try XCTUnwrap(properties["lastName"]) - guard case let .string(lastName) = lastNameProperty.kind else { - XCTFail("The 'lastName' property is not a string: \(lastNameProperty.kind)") - return - } - XCTAssertEqual(lastName, person.lastName) - XCTAssertEqual(try modelOutput.value(forProperty: "lastName"), person.lastName) - let ageProperty = try XCTUnwrap(properties["age"]) - guard case let .number(age) = ageProperty.kind else { - XCTFail("The 'age' property is not a number: \(ageProperty.kind)") - return + #if canImport(FoundationModels) + func testConvertGenerableTypeToModelOutput() throws { + let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") + let person = Person( + firstName: "Jane", + middleName: "Marie", + lastName: "Smith", + age: 32, + address: address + ) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + XCTFail("Model output is not a structure.") + return + } + let firstNameProperty = try XCTUnwrap(properties["firstName"]) + guard case let .string(firstName) = firstNameProperty.kind else { + XCTFail("The 'firstName' property is not a string: \(firstNameProperty.kind)") + return + } + XCTAssertEqual(firstName, person.firstName) + XCTAssertEqual(try modelOutput.value(forProperty: "firstName"), person.firstName) + let middleNameProperty = try XCTUnwrap(properties["middleName"]) + guard case let .string(middleName) = middleNameProperty.kind else { + XCTFail("The 'middleName' property is not a string: \(middleNameProperty.kind)") + return + } + XCTAssertEqual(middleName, person.middleName) + XCTAssertEqual(try modelOutput.value(forProperty: "middleName"), person.middleName) + let lastNameProperty = try XCTUnwrap(properties["lastName"]) + guard case let .string(lastName) = lastNameProperty.kind else { + XCTFail("The 'lastName' property is not a string: \(lastNameProperty.kind)") + return + } + XCTAssertEqual(lastName, person.lastName) + XCTAssertEqual(try modelOutput.value(forProperty: "lastName"), person.lastName) + let ageProperty = try XCTUnwrap(properties["age"]) + guard case let .number(age) = ageProperty.kind else { + XCTFail("The 'age' property is not a number: \(ageProperty.kind)") + return + } + XCTAssertEqual(Int(age), person.age) + XCTAssertEqual(try modelOutput.value(forProperty: "age"), person.age) + let addressProperty: Address = try modelOutput.value(forProperty: "address") + XCTAssertEqual(addressProperty, person.address) + XCTAssertEqual(try modelOutput.value(), person) + XCTAssertEqual(orderedKeys, ["firstName", "middleName", "lastName", "age", "address"]) } - XCTAssertEqual(Int(age), person.age) - XCTAssertEqual(try modelOutput.value(forProperty: "age"), person.age) - let addressProperty: Address = try modelOutput.value(forProperty: "address") - XCTAssertEqual(addressProperty, person.address) - XCTAssertEqual(try modelOutput.value(), person) - XCTAssertEqual(orderedKeys, ["firstName", "middleName", "lastName", "age", "address"]) - } + #endif // canImport(FoundationModels) func testConvertGenerableWithNilOptionalPropertyToModelOutput() throws { let address = Address(street: "789 Pine Ln", city: "Nowhere", zipCode: "00000") @@ -304,7 +306,7 @@ struct Person: Equatable { } #else @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Person: FirebaseAILogic.Generable { + extension Person: FirebaseAILogic.FirebaseGenerable { init(_ content: FirebaseAILogic.ModelOutput) throws { firstName = try content.value(forProperty: "firstName") middleName = try content.value(forProperty: "middleName") @@ -358,7 +360,7 @@ struct Address: Equatable { } #else @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Address: FirebaseAILogic.Generable { + extension Address: FirebaseAILogic.FirebaseGenerable { init(_ content: FirebaseAILogic.ModelOutput) throws { street = try content.value(forProperty: "street") city = try content.value(forProperty: "city")