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/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index fe2b6963e22..079a5dc083e 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -28,7 +28,7 @@ public struct GenerationConfig: Sendable { let topK: Int? /// The number of response variations to return. - let candidateCount: Int? + var candidateCount: Int? /// Maximum number of tokens that can be generated in the response. let maxOutputTokens: Int? @@ -43,18 +43,18 @@ public struct GenerationConfig: Sendable { let stopSequences: [String]? /// Output response MIME type of the generated candidate text. - let responseMIMEType: String? + var responseMIMEType: String? /// Output schema of the generated candidate text. - let responseSchema: Schema? + var responseSchema: Schema? /// Output schema of the generated response in [JSON Schema](https://json-schema.org/) format. /// /// If set, `responseSchema` must be omitted and `responseMIMEType` is required. - let responseJSONSchema: JSONObject? + var responseJSONSchema: JSONObject? /// Supported modalities of the response. - let responseModalities: [ResponseModality]? + var responseModalities: [ResponseModality]? /// Configuration for controlling the "thinking" behavior of compatible Gemini models. let thinkingConfig: ThinkingConfig? diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index cadb2728c70..68263e3d288 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -15,6 +15,9 @@ import FirebaseAppCheckInterop import FirebaseAuthInterop import Foundation +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) /// A type that represents a remote multimodal model (like Gemini), with the ability to generate /// content based on various input types. @@ -144,6 +147,12 @@ public final class GenerativeModel: Sendable { /// - Throws: A ``GenerateContentError`` if the request failed. public func generateContent(_ content: [ModelContent]) async throws -> GenerateContentResponse { + return try await generateContent(content, generationConfig: generationConfig) + } + + public func generateContent(_ content: [ModelContent], + generationConfig: GenerationConfig?) async throws + -> GenerateContentResponse { try content.throwIfError() let response: GenerateContentResponse let generateContentRequest = GenerateContentRequest( @@ -357,6 +366,52 @@ public final class GenerativeModel: Sendable { return try await generativeAIService.loadRequest(request: countTokensRequest) } + #if canImport(FoundationModels) + /// Produces a generable object as a response to a prompt. + /// + /// - Parameters: + /// - prompt: A prompt for the model to respond to. + /// - type: A type to produce as the response. + /// - Returns: ``GeneratedContent`` containing the fields and values defined in the schema. + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public final func generateObject(_ type: Content.Type = Content.self, + parts: any PartsRepresentable...) async throws + -> Response + where Content: FoundationModels.Generable { + let jsonSchema = try type.generationSchema.asGeminiJSONSchema() + + let generationConfig = { + var generationConfig = self.generationConfig ?? GenerationConfig() + if generationConfig.candidateCount != nil { + generationConfig.candidateCount = nil + } + generationConfig.responseMIMEType = "application/json" + if generationConfig.responseSchema != nil { + generationConfig.responseSchema = nil + } + generationConfig.responseJSONSchema = jsonSchema + if generationConfig.responseModalities != nil { + generationConfig.responseModalities = nil + } + + return generationConfig + }() + + let response = try await generateContent( + [ModelContent(parts: parts)], + generationConfig: generationConfig + ) + + let generatedContent = try GeneratedContent(json: response.text ?? "") + let content = try Content(generatedContent) + let rawContent = try ModelOutput(generatedContent) + + return Response(content: content, rawContent: rawContent) + } + #endif // canImport(FoundationModels) + /// Returns a `GenerateContentError` (for public consumption) from an internal error. /// /// If `error` is already a `GenerateContentError` the error is returned unchanged. @@ -366,4 +421,16 @@ public final class GenerativeModel: Sendable { } return GenerateContentError.internalError(underlying: error) } + + /// A structure that stores the output of a response call. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public struct Response { + /// The response content. + public let content: Content + + /// The raw response content. + /// + /// When `Content` is `ModelOutput`, this is the same as `content`. + public let rawContent: ModelOutput + } } diff --git a/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift b/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift new file mode 100644 index 00000000000..165d9b51650 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/FoundationModels/GenerationSchema+Gemini.swift @@ -0,0 +1,49 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import Foundation + import FoundationModels + + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension GenerationSchema { + func asGeminiJSONSchema() throws -> JSONObject { + let jsonData = try JSONEncoder().encode(self) + var jsonSchema = try JSONDecoder().decode(JSONObject.self, from: jsonData) + updatePropertyOrdering(&jsonSchema) + + return jsonSchema + } + } + + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + fileprivate func updatePropertyOrdering(_ schema: inout JSONObject) { + guard let propertyOrdering = schema.removeValue(forKey: "x-order") else { + return + } + guard case let .array(values) = propertyOrdering else { + return + } + guard values.allSatisfy({ + guard case .string = $0 else { return false } + return true + }) else { + return + } + + schema["propertyOrdering"] = propertyOrdering + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.swift new file mode 100644 index 00000000000..edd74095132 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleFromModelOutput.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. + +import Foundation +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +/// A type that can be initialized from model output. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +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 +} + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FoundationModels.GeneratedContent: ConvertibleFromModelOutput {} +#endif // canImport(FoundationModels) + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension FoundationModels.ConvertibleFromGeneratedContent + where Self: ConvertibleFromModelOutput { + init(_ content: ModelOutput) throws { + try self.init(content.generatedContent) + } + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift new file mode 100644 index 00000000000..6779eb29a7a --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ConvertibleToModelOutput.swift @@ -0,0 +1,99 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +/// A type that can be converted to model output. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public protocol ConvertibleToModelOutput { + /// 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 } +} + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FoundationModels.GeneratedContent: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + switch kind { + case .null: + return ModelOutput(kind: .null) + case let .bool(value): + return ModelOutput(kind: .bool(value)) + case let .number(value): + return ModelOutput(kind: .number(value)) + case let .string(value): + return ModelOutput(kind: .string(value)) + case let .array(values): + return ModelOutput(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return ModelOutput(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(kind)") + } + } + } +#endif // canImport(FoundationModels) + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension FoundationModels.ConvertibleToGeneratedContent + where Self: ConvertibleToModelOutput { + var generatedContent: GeneratedContent { + modelOutput.generatedContent + } + } +#endif // canImport(FoundationModels) + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension ConvertibleToModelOutput + where Self: FoundationModels.ConvertibleToGeneratedContent { + var modelOutput: ModelOutput { + generatedContent.modelOutput + } + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.swift b/FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.swift new file mode 100644 index 00000000000..815ed18cb0d --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/FirebaseGenerable.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 FirebaseGenerable: ConvertibleFromModelOutput, ConvertibleToModelOutput { + /// An instance of the JSON schema. + static var jsonSchema: JSONSchema { get } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension Optional where Wrapped: FirebaseGenerable {} + +@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: FirebaseGenerable { + 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: FirebaseGenerable { + 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: FirebaseGenerable { + 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: FirebaseGenerable { + 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: FirebaseGenerable { + 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: FirebaseGenerable { + 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: FirebaseGenerable where Element: FirebaseGenerable { + 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..6c9ceb031e5 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/JSONSchema.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. + +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: FirebaseGenerable.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: FirebaseGenerable.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: FirebaseGenerable { + 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: FirebaseGenerable { + 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 FirebaseGenerable.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 FirebaseGenerable.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 FirebaseGenerable.Type, description: String? = nil, + anyOf types: [any FirebaseGenerable.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/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift new file mode 100644 index 00000000000..ce6786f51f8 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -0,0 +1,312 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +/// A type that represents structured model output. +/// +/// Model output may contain a single value, an array, or key-value pairs with unique keys. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct ModelOutput: Sendable, FirebaseGenerable, 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: ", "))}" + } + } + } +} + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension ModelOutput: FoundationModels.ConvertibleFromGeneratedContent { + public init(_ content: GeneratedContent) throws { + switch content.kind { + case .null: + self.init(kind: .null) + case let .bool(value): + self.init(kind: .bool(value)) + case let .number(value): + self.init(kind: .number(value)) + case let .string(value): + self.init(kind: .string(value)) + case let .array(values): + self.init(kind: .array(values.map { $0.modelOutput })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + self.init(kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + )) + @unknown default: + fatalError("Unsupported GeneratedContent kind: \(content.kind)") + } + } + } +#endif // canImport(FoundationModels) + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension ModelOutput: FoundationModels.ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + switch modelOutput.kind { + case .null: + return GeneratedContent(kind: .null) + case let .bool(value): + return GeneratedContent(kind: .bool(value)) + case let .number(value): + return GeneratedContent(kind: .number(value)) + case let .string(value): + return GeneratedContent(kind: .string(value)) + case let .array(values): + return GeneratedContent(kind: .array(values.map { $0.generatedContent })) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return GeneratedContent(kind: .structure( + properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys + )) + } + } + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/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/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift new file mode 100644 index 00000000000..51e25f1add8 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -0,0 +1,370 @@ +// 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") + } + + #if canImport(FoundationModels) + func testConvertGenerableTypeToModelOutput() throws { + let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") + let person = Person( + firstName: "Jane", + middleName: "Marie", + lastName: "Smith", + age: 32, + address: address + ) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + XCTFail("Model output is not a structure.") + return + } + let firstNameProperty = try XCTUnwrap(properties["firstName"]) + guard case let .string(firstName) = firstNameProperty.kind else { + XCTFail("The 'firstName' property is not a string: \(firstNameProperty.kind)") + return + } + XCTAssertEqual(firstName, person.firstName) + XCTAssertEqual(try modelOutput.value(forProperty: "firstName"), person.firstName) + let middleNameProperty = try XCTUnwrap(properties["middleName"]) + guard case let .string(middleName) = middleNameProperty.kind else { + XCTFail("The 'middleName' property is not a string: \(middleNameProperty.kind)") + return + } + XCTAssertEqual(middleName, person.middleName) + XCTAssertEqual(try modelOutput.value(forProperty: "middleName"), person.middleName) + let lastNameProperty = try XCTUnwrap(properties["lastName"]) + guard case let .string(lastName) = lastNameProperty.kind else { + XCTFail("The 'lastName' property is not a string: \(lastNameProperty.kind)") + return + } + XCTAssertEqual(lastName, person.lastName) + XCTAssertEqual(try modelOutput.value(forProperty: "lastName"), person.lastName) + let ageProperty = try XCTUnwrap(properties["age"]) + guard case let .number(age) = ageProperty.kind else { + XCTFail("The 'age' property is not a number: \(ageProperty.kind)") + return + } + XCTAssertEqual(Int(age), person.age) + XCTAssertEqual(try modelOutput.value(forProperty: "age"), person.age) + let addressProperty: Address = try modelOutput.value(forProperty: "address") + XCTAssertEqual(addressProperty, person.address) + XCTAssertEqual(try modelOutput.value(), person) + XCTAssertEqual(orderedKeys, ["firstName", "middleName", "lastName", "age", "address"]) + } + #endif // canImport(FoundationModels) + + 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.FirebaseGenerable { + 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.FirebaseGenerable { + 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.FirebaseGenerable { + 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.FirebaseGenerable { + 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)