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. 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/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift new file mode 100644 index 00000000000..ad6cddf2a82 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift @@ -0,0 +1,44 @@ +// 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 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. + /// + /// 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 model output, decode the values as shown below: + /// + /// ```swift + /// struct Person: ConvertibleFromModelOutput { + /// var name: String + /// var age: Int + /// + /// init(_ content: ModelOutput) { + /// self.name = try content.value(forProperty: "firstName") + /// self.age = try content.value(forProperty: "ageInYears") + /// } + /// } + /// ``` + /// + /// - Important: If your type also conforms to ``ConvertibleToModelOutput``, it is critical + /// that this implementation be symmetrical with + /// ``ConvertibleToModelOutput/modelOutput``. + /// + /// - SeeAlso: `@Generable` macro ``Generable(description:)`` + init(_ content: ModelOutput) throws +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift new file mode 100644 index 00000000000..93367388510 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.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. + +/// 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. + /// + /// 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 `modelOutput` + /// property as shown below, to manually return a new ``ModelOutput`` with the properties + /// you specify. + /// + /// ```swift + /// struct Person: ConvertibleToModelOutput { + /// var name: String + /// var age: Int + /// + /// var modelOutput: ModelOutput { + /// ModelOutput(properties: [ + /// "firstName": name, + /// "ageInYears": age + /// ]) + /// } + /// } + /// ``` + /// + /// - Important: If your type also conforms to ``ConvertibleFromModelOutput``, it is + /// critical that this implementation be symmetrical with + /// ``ConvertibleFromModelOutput/init(_:)``. + var modelOutput: ModelOutput { 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..5cd73a52dd8 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/Generable.swift @@ -0,0 +1,205 @@ +// 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:)``. +@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) } + + return ModelOutput(self) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .bool(value) = content.kind else { + throw Self.decodingFailure(content) + } + self = value + } + + public var modelOutput: ModelOutput { + return ModelOutput(kind: .bool(self)) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .string(value) = content.kind else { + throw Self.decodingFailure(content) + } + self = value + } + + public var modelOutput: ModelOutput { + return ModelOutput(kind: .string(self)) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .number(value) = content.kind, let integer = Int(exactly: value) else { + throw Self.decodingFailure(content) + } + self = integer + } + + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(Double(self))) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .number(value) = content.kind else { + throw Self.decodingFailure(content) + } + self = Float(value) + } + + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(Double(self))) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .number(value) = content.kind else { + throw Self.decodingFailure(content) + } + self = value + } + + public var modelOutput: ModelOutput { + return ModelOutput(kind: .number(self)) + } +} + +@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") + } + + public init(_ content: ModelOutput) throws { + guard case let .number(value) = content.kind else { + throw Self.decodingFailure(content) + } + self = Decimal(value) + } + + public var modelOutput: ModelOutput { + let doubleValue = (self as NSDecimalNumber).doubleValue + return ModelOutput(kind: .number(doubleValue)) + } +} + +@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 } + return ModelOutput(kind: .array(values)) + } +} + +@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 { + guard case let .array(values) = content.kind else { + throw Self.decodingFailure(content) + } + self = try values.map { try Element($0) } + } +} + +@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 { + 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/GenerationGuide.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.swift new file mode 100644 index 00000000000..1ab6c9b3c50 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerationGuide.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. + +/// 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/GenerativeModel+GenerationError.swift b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift new file mode 100644 index 00000000000..421378b0fca --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/GenerativeModel+GenerationError.swift @@ -0,0 +1,44 @@ +// 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 + +@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 { + /// 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) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift new file mode 100644 index 00000000000..15538214291 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.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 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. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct JSONSchema: Sendable { + enum Kind: Sendable { + 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 JSON 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: [JSONSchema.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 JSON 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: 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: 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: 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: JSONSchema.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 } + } + + /// 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.jsonSchema.asOpenAPISchema()) + case let .object(name: name, description: description, properties: properties): + var objectProperties = [String: Schema]() + for property in properties { + objectProperties[property.name] = property.type.jsonSchema.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 + ) + } + } +} 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/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift new file mode 100644 index 00000000000..40be1ba638e --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -0,0 +1,254 @@ +// 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 model output. +/// +/// Model output may contain a single value, an array, or key-value pairs with unique keys. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { + /// 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. + public let kind: Kind + + /// An instance of the JSON schema. + public static var jsonSchema: JSONSchema { + // 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" : "ModelOutput" + // } + fatalError("`ModelOutput.generationSchema` is not implemented.") + } + + init(kind: Kind) { + self.kind = kind + } + + /// Creates model output from another value. + /// + /// This is used to satisfy `Generable.init(_:)`. + public init(_ content: ModelOutput) throws { + self = content + } + + /// 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 + /// properties in the types `schema`. + public init(properties: KeyValuePairs) { + fatalError("`ModelOutput.init(properties:)` is not implemented.") + } + + /// 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 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 + /// 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 = ModelOutput( + /// properties: [("name", "John"), ("name", "Jane"), ("married", true)], + /// uniquingKeysWith: { (first, _) in first } + /// ) + /// // 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 ``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: (ModelOutput, ModelOutput) throws + -> some ConvertibleToModelOutput) rethrows where S: Sequence, S.Element == ( + String, + any ConvertibleToModelOutput + ) { + var propertyNames = [String]() + var propertyMap = [String: ModelOutput]() + for (key, value) in properties { + if !propertyNames.contains(key) { + propertyNames.append(key) + 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.modelOutput) + propertyMap[key] = deduplicatedProperty.modelOutput + } + } + + 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 ConvertibleToModelOutput { + fatalError("`ModelOutput.init(elements:)` is not implemented.") + } + + /// Creates content that contains a single value. + /// + /// - Parameters: + /// - value: The underlying value. + 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: ConvertibleFromModelOutput { + return try Value(self) + } + + /// Reads a concrete `Generable` type from named property. + public func value(_ type: Value.Type = Value.self, + forProperty property: String) throws -> Value + where Value: ConvertibleFromModelOutput { + guard case let .structure(properties, _) = kind else { + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(Self.self) does not contain an object. + Content: \(kind) + """) + ) + } + guard let value = properties[property] else { + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: """ + \(Self.self) does not contain a property '\(property)'. + Content: \(self) + """) + ) + } + + 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 { + guard case let .structure(properties, _) = kind else { + 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 + } + + return try Value(value) + } +} + +@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`. + /// + /// `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, CustomDebugStringConvertible { + /// 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 `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 ``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/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 diff --git a/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift new file mode 100644 index 00000000000..207254ed144 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Macros/GenerableMacroTests.swift @@ -0,0 +1,54 @@ +// 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. + +// 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 + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + final class GenerableMacroTests: XCTestCase { + static let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] + + func testMacro() throws { + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: GenerableMacroTests.testMacros + ) + } + + func testMacroWithStringLiteral() throws { + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: GenerableMacroTests.testMacros + ) + } + } +#endif // canImport(FirebaseAILogicMacros) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift new file mode 100644 index 00000000000..dd9ffb9cf99 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -0,0 +1,368 @@ +// 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. + +@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)] = + [("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) + + 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") + } + + func testInitializeGenerableWithMissingPropertyThrows() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("age", 40)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + 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") + } + } + + func testInitializeGenerableFromNonStructureThrows() throws { + let modelOutput = ModelOutput("not a structure") + + 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") + } + } + + func testInitializeGenerableWithTypeMismatchThrows() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + 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") + } + } + + func testInitializeGenerableWithLossyNumericConversion() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40.5)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + 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.") + } + } + + func testInitializeGenerableWithExtraProperties() 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) + + 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") + } + + func testInitializeGenerableWithMissingOptionalProperty() 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) + + 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") + } + + func testConvertGenerableTypeToModelOutput() throws { + let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") + let person = Person( + firstName: "Jane", + middleName: "Marie", + lastName: "Smith", + age: 32, + address: address + ) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + XCTFail("Model output is not a structure.") + return + } + let firstNameProperty = try XCTUnwrap(properties["firstName"]) + guard case let .string(firstName) = firstNameProperty.kind else { + XCTFail("The 'firstName' property is not a string: \(firstNameProperty.kind)") + return + } + XCTAssertEqual(firstName, person.firstName) + XCTAssertEqual(try modelOutput.value(forProperty: "firstName"), person.firstName) + let middleNameProperty = try XCTUnwrap(properties["middleName"]) + guard case let .string(middleName) = middleNameProperty.kind else { + XCTFail("The 'middleName' property is not a string: \(middleNameProperty.kind)") + return + } + XCTAssertEqual(middleName, person.middleName) + XCTAssertEqual(try modelOutput.value(forProperty: "middleName"), person.middleName) + let lastNameProperty = try XCTUnwrap(properties["lastName"]) + guard case let .string(lastName) = lastNameProperty.kind else { + XCTFail("The 'lastName' property is not a string: \(lastNameProperty.kind)") + return + } + XCTAssertEqual(lastName, person.lastName) + XCTAssertEqual(try modelOutput.value(forProperty: "lastName"), person.lastName) + let ageProperty = try XCTUnwrap(properties["age"]) + guard case let .number(age) = ageProperty.kind else { + XCTFail("The 'age' property is not a number: \(ageProperty.kind)") + return + } + XCTAssertEqual(Int(age), person.age) + XCTAssertEqual(try modelOutput.value(forProperty: "age"), person.age) + let addressProperty: Address = try modelOutput.value(forProperty: "address") + XCTAssertEqual(addressProperty, person.address) + XCTAssertEqual(try modelOutput.value(), person) + XCTAssertEqual(orderedKeys, ["firstName", "middleName", "lastName", "age", "address"]) + } + + func testConvertGenerableWithNilOptionalPropertyToModelOutput() 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 + ) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + XCTFail("Model output is not a structure.") + return + } + XCTAssertNil(properties["middleName"]) + XCTAssertEqual(orderedKeys, ["firstName", "lastName", "age", "address"]) + } + + func testPersonJSONSchema() throws { + let schema = Person.jsonSchema + + guard case let .object(_, _, properties) = schema.kind else { + XCTFail("Schema kind is not an object.") + return + } + + 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) + } +} + +// 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 + } + ) + } +} + +#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") + address = try content.value(forProperty: "address") + } + } +#else + @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 { + 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 // 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 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 { + init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") + } + } +#endif // compiler(>=6.2) 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..5884573b241 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"), + swiftSyntaxDependency(), ], targets: [ .target( @@ -189,26 +191,35 @@ 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: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "FirebaseAI/Sources/Macros" ), .testTarget( name: "FirebaseAILogicUnit", dependencies: [ "FirebaseAILogic", "FirebaseStorage", + "FirebaseAILogicMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ], path: "FirebaseAI/Tests/Unit", resources: [ .copy("vertexai-sdk-test-data/mock-responses"), .process("Resources"), - ], - cSettings: [ - .headerSearchPath("../../../"), ] ), .target( @@ -1448,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(