From e150802035530b4b7ec77949853125c7b71572e9 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Sun, 23 Nov 2025 21:00:33 -0500 Subject: [PATCH 01/32] [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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 b81c1549ed8be87a99c338ef34cf2ec165a01bdf Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 17:31:03 -0500 Subject: [PATCH 23/32] Add `FirebaseAILogicMacros` SPM target with placeholder macro --- .../Macros/FirebaseAILogicPlugin.swift | 23 +++++++ .../Sources/Macros/StringifyMacro.swift | 37 +++++++++++ .../Unit/Macros/GenerableMacroTests.swift | 63 +++++++++++++++++++ FirebaseAILogic.podspec | 4 ++ Package.swift | 18 +++++- 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 FirebaseAI/Sources/Macros/FirebaseAILogicPlugin.swift create mode 100644 FirebaseAI/Sources/Macros/StringifyMacro.swift create mode 100644 FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift diff --git a/FirebaseAI/Sources/Macros/FirebaseAILogicPlugin.swift b/FirebaseAI/Sources/Macros/FirebaseAILogicPlugin.swift new file mode 100644 index 00000000000..104736c6fd2 --- /dev/null +++ b/FirebaseAI/Sources/Macros/FirebaseAILogicPlugin.swift @@ -0,0 +1,23 @@ +// 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 SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct FirebaseAILogicPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + StringifyMacro.self, + ] +} diff --git a/FirebaseAI/Sources/Macros/StringifyMacro.swift b/FirebaseAI/Sources/Macros/StringifyMacro.swift new file mode 100644 index 00000000000..2f18067b6ae --- /dev/null +++ b/FirebaseAI/Sources/Macros/StringifyMacro.swift @@ -0,0 +1,37 @@ +// 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 SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Implementation of the `stringify` macro, which takes an expression +/// of any type and produces a tuple containing the value of that expression +/// and the source code that produced the value. For example +/// +/// #stringify(x + y) +/// +/// will expand to +/// +/// (x + y, "x + y") +public struct StringifyMacro: ExpressionMacro { + public static func expansion(of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext) -> ExprSyntax { + guard let argument = node.arguments.first?.expression else { + fatalError("compiler bug: the macro does not have any arguments") + } + + return "(\(argument), \(literal: argument.description))" + } +} diff --git a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift new file mode 100644 index 00000000000..55aa4ce4591 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift @@ -0,0 +1,63 @@ +// 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 SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when +// cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(FirebaseAILogicMacros) + import FirebaseAILogicMacros +#endif + +final class GenerableMacroTests: XCTestCase { + #if canImport(FirebaseAILogicMacros) + let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] + #else + let testMacros = [String: Macro.Type]() + + override func setUpWithError() throws { + throw XCTSkip("Macros are only supported when running tests for the host platform.") + } + #endif // canImport(FirebaseAILogicMacros) + + func testMacro() throws { + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: testMacros + ) + } + + func testMacroWithStringLiteral() throws { + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: testMacros + ) + } +} diff --git a/FirebaseAILogic.podspec b/FirebaseAILogic.podspec index 58638c2737f..c9f535ae4b3 100644 --- a/FirebaseAILogic.podspec +++ b/FirebaseAILogic.podspec @@ -34,6 +34,9 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI Log s.source_files = [ 'FirebaseAI/Sources/**/*.swift', ] + s.exclude_files = [ + 'FirebaseAI/Sources/Macros', + ] s.swift_version = '6.0' @@ -60,6 +63,7 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI Log unit_tests_dir + '**/*.swift', ] unit_tests.exclude_files = [ + unit_tests_dir + 'Macros/**/*.swift', unit_tests_dir + 'Snippets/**/*.swift', ] unit_tests.resources = [ diff --git a/Package.swift b/Package.swift index 41afc02d1b4..11f04b728de 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import CompilerPluginSupport import PackageDescription let firebaseVersion = "12.7.0" @@ -176,6 +177,7 @@ let package = Package( ), .package(url: "https://github.com/google/app-check.git", "11.0.1" ..< "12.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.1" ..< "601.0.0"), ], targets: [ .target( @@ -189,18 +191,32 @@ let package = Package( .target( name: "FirebaseAILogic", dependencies: [ + "FirebaseAILogicMacros", "FirebaseAppCheckInterop", "FirebaseAuthInterop", "FirebaseCore", "FirebaseCoreExtension", ], - path: "FirebaseAI/Sources" + path: "FirebaseAI/Sources", + exclude: ["Macros"] + ), + .macro( + name: "FirebaseAILogicMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "FirebaseAI/Sources/Macros" ), .testTarget( name: "FirebaseAILogicUnit", dependencies: [ "FirebaseAILogic", + "FirebaseAILogicMacros", "FirebaseStorage", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ], path: "FirebaseAI/Tests/Unit", resources: [ From 551cfd73be86345a72fc2f0d28a18bd7e044a5be Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 18:11:25 -0500 Subject: [PATCH 24/32] Workaround transient SPM build issues due to prebuilt swift-syntax --- .../Unit/Macros/GenerableMacroTests.swift | 65 ++++++++----------- Package.swift | 11 +++- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift index 55aa4ce4591..bf04d14ca0f 100644 --- a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift +++ b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift @@ -12,52 +12,43 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SwiftSyntax -import SwiftSyntaxBuilder -import SwiftSyntaxMacros -import SwiftSyntaxMacrosTestSupport -import XCTest - // Macro implementations build for the host, so the corresponding module is not available when // cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. #if canImport(FirebaseAILogicMacros) import FirebaseAILogicMacros -#endif + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest -final class GenerableMacroTests: XCTestCase { - #if canImport(FirebaseAILogicMacros) + final class GenerableMacroTests: XCTestCase { let testMacros: [String: Macro.Type] = [ "stringify": StringifyMacro.self, ] - #else - let testMacros = [String: Macro.Type]() - override func setUpWithError() throws { - throw XCTSkip("Macros are only supported when running tests for the host platform.") + func testMacro() throws { + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: testMacros + ) } - #endif // canImport(FirebaseAILogicMacros) - - func testMacro() throws { - assertMacroExpansion( - """ - #stringify(a + b) - """, - expandedSource: """ - (a + b, "a + b") - """, - macros: testMacros - ) - } - func testMacroWithStringLiteral() throws { - assertMacroExpansion( - #""" - #stringify("Hello, \(name)") - """#, - expandedSource: #""" - ("Hello, \(name)", #""Hello, \(name)""#) - """#, - macros: testMacros - ) + func testMacroWithStringLiteral() throws { + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: testMacros + ) + } } -} +#endif // canImport(FirebaseAILogicMacros) diff --git a/Package.swift b/Package.swift index 11f04b728de..23f228cc80a 100644 --- a/Package.swift +++ b/Package.swift @@ -214,9 +214,16 @@ let package = Package( name: "FirebaseAILogicUnit", dependencies: [ "FirebaseAILogic", - "FirebaseAILogicMacros", "FirebaseStorage", - .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + .target( + name: "FirebaseAILogicMacros", + condition: .when(platforms: [.macOS]) + ), + .product( + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax", + condition: .when(platforms: [.macOS]) + ), ], path: "FirebaseAI/Tests/Unit", resources: [ From 94f989f2e4687c935bb0e3f0433bf42763fb4fdd Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 18:13:17 -0500 Subject: [PATCH 25/32] Revert "Try forcing Swift 6 mode on macOS 14 / Xcode 16.2" This reverts commit ad396d5feb4edc9dbccf03cf3fa69a15e4c6eb0d. # Conflicts: # FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift --- .github/workflows/firebaseai.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/firebaseai.yml b/.github/workflows/firebaseai.yml index 6aa1adc666f..1f3cde4a26a 100644 --- a/.github/workflows/firebaseai.yml +++ b/.github/workflows/firebaseai.yml @@ -78,9 +78,7 @@ jobs: with: product: ${{ matrix.product }} supports_swift6: true - 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 + setup_command: scripts/update_vertexai_responses.sh quickstart: if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' From 21816b0720bf456f2c61ecb215f2f0aec45b3a02 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 19:13:00 -0500 Subject: [PATCH 26/32] Try removing `spm-package-resolved` job --- .github/workflows/common.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 4af29b0f68b..1611900e7e4 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -85,7 +85,6 @@ jobs: spm: # Run on the main repo's scheduled jobs or pull requests and manual workflow invocations. if: (github.repository == 'firebase/firebase-ios-sdk' && github.event_name == 'schedule') || contains(fromJSON('["pull_request", "workflow_dispatch"]'), github.event_name) - needs: [spm-package-resolved] strategy: matrix: os: [macos-15] @@ -101,10 +100,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: actions/cache/restore@v4 - with: - path: .build - key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer - name: Run setup command, if needed. From fb5f2e2f2ca7716acc49c962ddbad1902e317265 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 19:39:52 -0500 Subject: [PATCH 27/32] Revert "Try removing `spm-package-resolved` job" This reverts commit 21816b0720bf456f2c61ecb215f2f0aec45b3a02. --- .github/workflows/common.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 1611900e7e4..4af29b0f68b 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -85,6 +85,7 @@ jobs: spm: # Run on the main repo's scheduled jobs or pull requests and manual workflow invocations. if: (github.repository == 'firebase/firebase-ios-sdk' && github.event_name == 'schedule') || contains(fromJSON('["pull_request", "workflow_dispatch"]'), github.event_name) + needs: [spm-package-resolved] strategy: matrix: os: [macos-15] @@ -100,6 +101,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - uses: actions/cache/restore@v4 + with: + path: .build + key: ${{needs.spm-package-resolved.outputs.cache_key}} - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer - name: Run setup command, if needed. From fdb4dbf030347c642f370c179e404a66980ca317 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 19:44:47 -0500 Subject: [PATCH 28/32] Set `IDEPackageEnablePrebuilts NO` --- .github/workflows/common.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 4af29b0f68b..e02a4a2713e 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -68,6 +68,8 @@ jobs: - uses: actions/checkout@v4 - name: Xcode run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + - name: Disable SPM Prebuilts + run: defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts NO - name: Generate Swift Package.resolved id: swift_package_resolve run: swift package resolve @@ -112,11 +114,13 @@ jobs: run: ${{ inputs.setup_command }} - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh + - name: Disable SPM Prebuilts + run: defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts NO - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 if: contains(join(inputs.platforms), matrix.platform) || matrix.os == 'macos-14' with: timeout_minutes: 15 - max_attempts: 3 + max_attempts: 1 # TODO: Change back to 3 after testing retry_wait_seconds: 120 command: | ./scripts/build.sh \ From 2395e13ce634dc75df875fc41aca21a862e6b62a Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Nov 2025 20:03:35 -0500 Subject: [PATCH 29/32] Revert "Set `IDEPackageEnablePrebuilts NO`" This reverts commit fdb4dbf030347c642f370c179e404a66980ca317. --- .github/workflows/common.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index e02a4a2713e..4af29b0f68b 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -68,8 +68,6 @@ jobs: - uses: actions/checkout@v4 - name: Xcode run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer - - name: Disable SPM Prebuilts - run: defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts NO - name: Generate Swift Package.resolved id: swift_package_resolve run: swift package resolve @@ -114,13 +112,11 @@ jobs: run: ${{ inputs.setup_command }} - name: Initialize xcodebuild run: scripts/setup_spm_tests.sh - - name: Disable SPM Prebuilts - run: defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts NO - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 if: contains(join(inputs.platforms), matrix.platform) || matrix.os == 'macos-14' with: timeout_minutes: 15 - max_attempts: 1 # TODO: Change back to 3 after testing + max_attempts: 3 retry_wait_seconds: 120 command: | ./scripts/build.sh \ From 33bda287864964eda417e8536b07a1eb7cd8e59f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 28 Nov 2025 11:51:24 -0500 Subject: [PATCH 30/32] Refactor to more closely match starter project from Xcode 16.2 --- .../Public/Generable/MacroDeclarations.swift | 23 +++++++++++++++++++ .../Unit/Macros/GenerableMacroTests.swift | 16 +++++++++---- Package.swift | 18 +++------------ 3 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/Generable/MacroDeclarations.swift diff --git a/FirebaseAI/Sources/Types/Public/Generable/MacroDeclarations.swift b/FirebaseAI/Sources/Types/Public/Generable/MacroDeclarations.swift new file mode 100644 index 00000000000..ddee87d671b --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/MacroDeclarations.swift @@ -0,0 +1,23 @@ +// 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 macro that produces both a value and a string containing the +/// source code that generated the value. For example, +/// +/// #stringify(x + y) +/// +/// produces a tuple `(x + y, "x + y")`. +@freestanding(expression) +public macro stringify(_ value: T) -> (T, String) = + #externalMacro(module: "FirebaseAILogicMacros", type: "StringifyMacro") diff --git a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift index bf04d14ca0f..8f0978214ad 100644 --- a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift +++ b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift @@ -23,9 +23,15 @@ import XCTest final class GenerableMacroTests: XCTestCase { - let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, - ] + #if swift(>=6.2) + nonisolated(unsafe) static let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] + #else + static let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] + #endif // swift(>=6.2) func testMacro() throws { assertMacroExpansion( @@ -35,7 +41,7 @@ expandedSource: """ (a + b, "a + b") """, - macros: testMacros + macros: GenerableMacroTests.testMacros ) } @@ -47,7 +53,7 @@ expandedSource: #""" ("Hello, \(name)", #""Hello, \(name)""#) """#, - macros: testMacros + macros: GenerableMacroTests.testMacros ) } } diff --git a/Package.swift b/Package.swift index 23f228cc80a..528c340da21 100644 --- a/Package.swift +++ b/Package.swift @@ -177,7 +177,7 @@ let package = Package( ), .package(url: "https://github.com/google/app-check.git", "11.0.1" ..< "12.0.0"), - .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.1" ..< "601.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"), ], targets: [ .target( @@ -203,8 +203,6 @@ let package = Package( .macro( name: "FirebaseAILogicMacros", dependencies: [ - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), ], @@ -215,23 +213,13 @@ let package = Package( dependencies: [ "FirebaseAILogic", "FirebaseStorage", - .target( - name: "FirebaseAILogicMacros", - condition: .when(platforms: [.macOS]) - ), - .product( - name: "SwiftSyntaxMacrosTestSupport", - package: "swift-syntax", - condition: .when(platforms: [.macOS]) - ), + "FirebaseAILogicMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ], path: "FirebaseAI/Tests/Unit", resources: [ .copy("vertexai-sdk-test-data/mock-responses"), .process("Resources"), - ], - cSettings: [ - .headerSearchPath("../../../"), ] ), .target( From 45c4300244ab16c4ecf45061e176e75f702b6538 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 28 Nov 2025 12:38:19 -0500 Subject: [PATCH 31/32] Switch `swift-syntax` dependency version based on Swift version --- .../Tests/Unit/Macros/GenerableMacroTests.swift | 12 +++--------- Package.swift | 13 ++++++++++++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift index 8f0978214ad..207254ed144 100644 --- a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift +++ b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift @@ -23,15 +23,9 @@ import XCTest final class GenerableMacroTests: XCTestCase { - #if swift(>=6.2) - nonisolated(unsafe) static let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, - ] - #else - static let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, - ] - #endif // swift(>=6.2) + static let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] func testMacro() throws { assertMacroExpansion( diff --git a/Package.swift b/Package.swift index 528c340da21..5884573b241 100644 --- a/Package.swift +++ b/Package.swift @@ -177,7 +177,7 @@ let package = Package( ), .package(url: "https://github.com/google/app-check.git", "11.0.1" ..< "12.0.0"), - .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"), + swiftSyntaxDependency(), ], targets: [ .target( @@ -1459,6 +1459,17 @@ func grpcDependency() -> Package.Dependency { return .package(url: packageInfo.url, packageInfo.range) } +func swiftSyntaxDependency() -> Package.Dependency { + let url = "https://github.com/swiftlang/swift-syntax.git" + #if swift(>=6.2) + return .package(url: url, from: "602.0.0-latest") + #elseif swift(>=6.1) + return .package(url: url, from: "601.0.0-latest") + #else + return .package(url: url, from: "600.0.0-latest") + #endif +} + func firestoreWrapperTarget() -> Target { if Context.environment["FIREBASE_SOURCE_FIRESTORE"] != nil { return .target( From e817d78d67ac97d7c24037d026d4587f855d1879 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 28 Nov 2025 13:40:30 -0500 Subject: [PATCH 32/32] Try disabling Swift prebuilts again --- .github/workflows/common.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 4af29b0f68b..486f07d1995 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -105,6 +105,12 @@ jobs: with: path: .build key: ${{needs.spm-package-resolved.outputs.cache_key}} + - name: Disable SwiftPM prebuilts in Xcode + run: | + defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts -bool NO + defaults read com.apple.dt.Xcode IDEPackageEnablePrebuilts + - name: Clear SwiftPM caches + run: rm -rf ~/.swiftpm/artifacts ~/.swiftpm/cache - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer - name: Run setup command, if needed.