From a66243ab8ca7139ebc1c2fc827a28b18991a154c Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 11:12:54 -0800 Subject: [PATCH 01/12] Add a json schema integration test --- .../GenerateContentIntegrationTests.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 046d49ff4c0..e5ce12a730c 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -139,6 +139,74 @@ struct GenerateContentIntegrationTests { #expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount) } + @Test("Generate a JSON object", arguments: InstanceConfig.allConfigs) + func generateContentJSONObject(_ config: InstanceConfig) async throws { + struct Recipe: Codable { + let name: String + let ingredients: [Ingredient] + let isDelicious: Bool + } + + struct Ingredient: Codable { + let name: String + let quantity: Int + } + + let expectedResponse = Recipe( + name: "Apple Pie", + ingredients: [ + Ingredient(name: "Apple", quantity: 6), + Ingredient(name: "Cinnamon", quantity: 1), + Ingredient(name: "Sugar", quantity: 1), + ], + isDelicious: true + ) + let recipeSchema = Schema.object(properties: [ + "name": .string(), + "ingredients": .array(items: .object(properties: [ + "name": .string(), + "quantity": .integer(), + ])), + "isDelicious": .boolean(), + ]) + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + generationConfig: GenerationConfig( + responseMIMEType: "application/json", + responseSchema: recipeSchema + ), + safetySettings: safetySettings, + systemInstruction: ModelContent( + role: "system", + parts: "Always respond with a recipe for apple pie." + ) + ) + let prompt = "Give me a recipe for a dessert." + + let response = try await model.generateContent(prompt) + + let responseData = try #require(response.text?.data(using: .utf8)) + let recipe = try JSONDecoder().decode(Recipe.self, from: responseData) + #expect(recipe.name.lowercased() == expectedResponse.name.lowercased()) + #expect(recipe.ingredients.count >= expectedResponse.ingredients.count) + #expect(recipe.isDelicious == expectedResponse.isDelicious) + + let usageMetadata = try #require(response.usageMetadata) + #expect(usageMetadata.promptTokenCount.isEqual(to: 36, accuracy: tokenCountAccuracy)) + #expect(usageMetadata.candidatesTokenCount >= 92) + #expect(usageMetadata.thoughtsTokenCount == 0) + #expect(usageMetadata.totalTokenCount + == usageMetadata.promptTokenCount + usageMetadata.candidatesTokenCount) + #expect(usageMetadata.promptTokensDetails.count == 1) + let promptTokensDetails = try #require(usageMetadata.promptTokensDetails.first) + #expect(promptTokensDetails.modality == .text) + #expect(promptTokensDetails.tokenCount == usageMetadata.promptTokenCount) + #expect(usageMetadata.candidatesTokensDetails.count == 1) + let candidatesTokensDetails = try #require(usageMetadata.candidatesTokensDetails.first) + #expect(candidatesTokensDetails.modality == .text) + #expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount) + } + @Test( arguments: [ (.vertexAI_v1beta, ModelNames.gemini2_5_Flash, ThinkingConfig(thinkingBudget: 0)), From 00d8d4abf962740c4581e1e6e3f895fb4037b982 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 14:01:47 -0800 Subject: [PATCH 02/12] Add FirebaseGenerable --- .../Types/Public/FirebaseGenerable.swift | 22 +++ .../Types/Public/FirebaseGenerableMacro.swift | 23 ++++ .../Public/Schema+FirebaseGenerable.swift | 27 ++++ .../GenerateContentIntegrationTests.swift | 49 +++++++ .../FirebaseGenerableMacro.swift | 130 ++++++++++++++++++ .../Sources/FirebaseAILogicMacro/Plugin.swift | 23 ++++ Package.swift | 14 ++ 7 files changed, 288 insertions(+) create mode 100644 FirebaseAI/Sources/Types/Public/FirebaseGenerable.swift create mode 100644 FirebaseAI/Sources/Types/Public/FirebaseGenerableMacro.swift create mode 100644 FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift create mode 100644 FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift create mode 100644 FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/Plugin.swift diff --git a/FirebaseAI/Sources/Types/Public/FirebaseGenerable.swift b/FirebaseAI/Sources/Types/Public/FirebaseGenerable.swift new file mode 100644 index 00000000000..f1c701a3636 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/FirebaseGenerable.swift @@ -0,0 +1,22 @@ +// 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 generated by a large language model. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public protocol FirebaseGenerable: Decodable { + /// A schema describing the structure of the generated type. + static var firebaseGenerationSchema: Schema { get } +} diff --git a/FirebaseAI/Sources/Types/Public/FirebaseGenerableMacro.swift b/FirebaseAI/Sources/Types/Public/FirebaseGenerableMacro.swift new file mode 100644 index 00000000000..36c2cf4c32d --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/FirebaseGenerableMacro.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 Foundation + +/// A macro that makes a ``Decodable`` type ``FirebaseGenerable``. +@attached(member, names: named(firebaseGenerationSchema)) +@attached(extension, conformances: FirebaseGenerable) +public macro FirebaseGenerable() = #externalMacro( + module: "FirebaseAILogicMacro", + type: "FirebaseGenerableMacro" +) diff --git a/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift b/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift new file mode 100644 index 00000000000..2cc8b026577 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift @@ -0,0 +1,27 @@ +// 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 Schema { + /// Returns a `Schema` representing a `Decodable` type. + static func from(_ type: T.Type) -> Schema { + if let type = type as? FirebaseGenerable.Type { + return type.firebaseGenerationSchema + } + + return .object(properties: [:]) + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index e5ce12a730c..b34887f6868 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -207,6 +207,55 @@ struct GenerateContentIntegrationTests { #expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount) } + @FirebaseGenerable + struct Ingredient: Codable { + let name: String + let quantity: Int + } + + @FirebaseGenerable + struct Dessert: Codable { + let name: String + let ingredients: [Ingredient] + let isDelicious: Bool + } + + @Test("Generate a JSON object with @FirebaseGenerable", arguments: InstanceConfig.allConfigs) + func generateContentWithFirebaseGenerable(_ config: InstanceConfig) async throws { + let expectedResponse = Dessert( + name: "Apple Pie", + ingredients: [ + Ingredient(name: "Apple", quantity: 6), + Ingredient(name: "Cinnamon", quantity: 1), + Ingredient(name: "Sugar", quantity: 1), + ], + isDelicious: true + ) + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + generationConfig: GenerationConfig( + responseMIMEType: "application/json", + responseSchema: Dessert.firebaseGenerationSchema + ), + safetySettings: safetySettings, + systemInstruction: ModelContent( + role: "system", + parts: "Always respond with a recipe for apple pie." + ) + ) + let prompt = "Give me a recipe for a dessert." + + let response = try await model.generateContent(prompt) + + let responseData = try #require(response.text?.data(using: .utf8)) + let dessert = try JSONDecoder().decode(Dessert.self, from: responseData) + #expect(dessert.name.lowercased() == expectedResponse.name.lowercased()) + #expect(dessert.ingredients.count >= expectedResponse.ingredients.count) + #expect(dessert.isDelicious == expectedResponse.isDelicious) + } + + + @Test( arguments: [ (.vertexAI_v1beta, ModelNames.gemini2_5_Flash, ThinkingConfig(thinkingBudget: 0)), diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift new file mode 100644 index 00000000000..09b652b27c9 --- /dev/null +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -0,0 +1,130 @@ +// 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 SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { + public static func expansion(of node: SwiftSyntax.AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context) throws + -> [SwiftSyntax.DeclSyntax] + where Declaration: SwiftSyntax.DeclGroupSyntax, + Context: SwiftSyntaxMacros.MacroExpansionContext { + let properties = declaration.memberBlock.members.compactMap { + $0.decl.as(VariableDeclSyntax.self) + } + + let schemaProperties: [String] = try properties.map { property in + let (name, type) = try property.toNameAndType() + return try """ + "\(name)": \(schema(for: type)) + """ + } + + return [ + """ + public static var firebaseGenerationSchema: FirebaseAILogic.Schema { + .object(properties: [ + \(raw: schemaProperties.joined(separator: ",\n")) + ]) + } + """, + ] + } + + public static func expansion(of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros + .MacroExpansionContext) throws + -> [SwiftSyntax.ExtensionDeclSyntax] { + if protocols.isEmpty { + return [] + } + + let inheritanceClause = InheritanceClauseSyntax { + for protocolType in protocols { + InheritedTypeSyntax(type: protocolType) + } + } + + let extensionDecl = ExtensionDeclSyntax( + extendedType: type.trimmed, + inheritanceClause: inheritanceClause + ) { + // Empty member block + } + + return [extensionDecl] + } + + private static func schema(for type: TypeSyntax) throws -> String { + if let type = type.as(IdentifierTypeSyntax.self) { + switch type.name.text { + case "String": + return ".string()" + case "Int", "Int8", "Int16", "Int32", "Int64", + "UInt", "UInt8", "UInt16", "UInt32", "UInt64": + return ".integer()" + case "Float", "Double": + return ".double()" + case "Bool": + return ".boolean()" + default: + return """ + .from(\(type).self) + """ + } + } else if let type = type.as(OptionalTypeSyntax.self) { + return try schema(for: type.wrappedType) + } else if let type = type.as(ArrayTypeSyntax.self) { + return try """ + .array(items: \(schema(for: type.element))) + """ + } + + throw MacroError.unsupportedType(Syntax(type)) + } +} + +private extension VariableDeclSyntax { + func toNameAndType() throws -> (String, TypeSyntax) { + guard let binding = bindings.first else { + throw MacroError.unsupportedType(Syntax(self)) + } + guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { + throw MacroError.unsupportedType(Syntax(self)) + } + guard let type = binding.typeAnnotation?.type else { + throw MacroError.unsupportedType(Syntax(self)) + } + + return (name, type) + } +} + +private enum MacroError: Error, CustomStringConvertible { + case unsupportedType(Syntax) + + var description: String { + switch self { + case let .unsupportedType(type): + return "Unsupported type: \(type)" + } + } +} diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/Plugin.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/Plugin.swift new file mode 100644 index 00000000000..d226d9c610d --- /dev/null +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/Plugin.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 FirebaseAILogicMacro: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + FirebaseGenerableMacro.self, + ] +} diff --git a/Package.swift b/Package.swift index 41afc02d1b4..f51d0a7e5a6 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" @@ -131,6 +132,10 @@ let package = Package( ), ], dependencies: [ + .package( + url: "https://github.com/apple/swift-syntax.git", + from: "600.0.1" + ), .package( url: "https://github.com/google/promises.git", "2.4.0" ..< "3.0.0" @@ -186,6 +191,14 @@ let package = Package( // MARK: - Firebase AI + .macro( + name: "FirebaseAILogicMacro", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "FirebaseAILogicMacros/Sources/FirebaseAILogicMacro" + ), .target( name: "FirebaseAILogic", dependencies: [ @@ -193,6 +206,7 @@ let package = Package( "FirebaseAuthInterop", "FirebaseCore", "FirebaseCoreExtension", + "FirebaseAILogicMacro", ], path: "FirebaseAI/Sources" ), From 4d82d6dac8a463c5e7781663b3ee446c6fa439ab Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 14:24:42 -0800 Subject: [PATCH 03/12] generateObject returning a FirebaseGenerable --- FirebaseAI/Sources/GenerateObjectError.swift | 29 +++++++++ .../GenerativeModel+GenerateObject.swift | 62 +++++++++++++++++++ .../GenerateContentIntegrationTests.swift | 29 ++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 FirebaseAI/Sources/GenerateObjectError.swift create mode 100644 FirebaseAI/Sources/GenerativeModel+GenerateObject.swift diff --git a/FirebaseAI/Sources/GenerateObjectError.swift b/FirebaseAI/Sources/GenerateObjectError.swift new file mode 100644 index 00000000000..3a3f7dced66 --- /dev/null +++ b/FirebaseAI/Sources/GenerateObjectError.swift @@ -0,0 +1,29 @@ +// 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 + +enum GenerateObjectError: Error, CustomStringConvertible { + case responseTextError(String) + case jsonDecodingError(Error) + + var description: String { + switch self { + case let .responseTextError(message): + return message + case let .jsonDecodingError(error): + return "Failed to decode JSON: \(error)" + } + } +} diff --git a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift new file mode 100644 index 00000000000..f2e3d7249b9 --- /dev/null +++ b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift @@ -0,0 +1,62 @@ +// 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 { + /// Generates a structured object of a specified type that conforms to ``FirebaseGenerable``. + /// + /// This method simplifies the process of generating structured data by handling the schema + /// generation, API request, and JSON decoding automatically. + /// + /// - Parameters: + /// - type: The `FirebaseGenerable` type to generate. + /// - prompt: The text prompt to send to the model. + /// - Returns: An instance of the requested type, decoded from the model's JSON response. + /// - Throws: A ``GenerateContentError`` if the model fails to generate the content or if + /// the response cannot be decoded into the specified type. + func generateObject(as type: T.Type, + from prompt: String) async throws -> T { + let model = GenerativeModel( + modelName: modelName, + modelResourceName: modelResourceName, + firebaseInfo: generativeAIService.firebaseInfo, + apiConfig: apiConfig, + generationConfig: GenerationConfig( + responseMIMEType: "application/json", + responseSchema: T.firebaseGenerationSchema + ), + safetySettings: safetySettings, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + requestOptions: requestOptions + ) + let response = try await model.generateContent(prompt) + + guard let text = response.text, let data = text.data(using: .utf8) else { + throw GenerateContentError.internalError( + underlying: GenerateObjectError.responseTextError("Failed to get response text or data.") + ) + } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw GenerateContentError + .internalError(underlying: GenerateObjectError.jsonDecodingError(error)) + } + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index b34887f6868..11315c4a971 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -151,7 +151,7 @@ struct GenerateContentIntegrationTests { let name: String let quantity: Int } - + let expectedResponse = Recipe( name: "Apple Pie", ingredients: [ @@ -254,7 +254,34 @@ struct GenerateContentIntegrationTests { #expect(dessert.isDelicious == expectedResponse.isDelicious) } + @Test("Generate a JSON object with generateObject", arguments: InstanceConfig.allConfigs) + func generateObject(_ config: InstanceConfig) async throws { + let expectedResponse = Dessert( + name: "Apple Pie", + ingredients: [ + Ingredient(name: "Apple", quantity: 6), + Ingredient(name: "Cinnamon", quantity: 1), + Ingredient(name: "Sugar", quantity: 1), + ], + isDelicious: true + ) + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + systemInstruction: ModelContent( + role: "system", + parts: "Always respond with a recipe for apple pie." + ) + ) + let dessert = try await model.generateObject( + as: Dessert.self, + from: "Give me a recipe for a dessert." + ) + + #expect(dessert.name.lowercased() == expectedResponse.name.lowercased()) + #expect(dessert.ingredients.count >= expectedResponse.ingredients.count) + #expect(dessert.isDelicious == expectedResponse.isDelicious) + } @Test( arguments: [ From a95049668564f3edb962099c7c8fbbd22c6565e8 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 14:30:49 -0800 Subject: [PATCH 04/12] Update FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index 09b652b27c9..fb2377a2343 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -81,7 +81,9 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { case "Int", "Int8", "Int16", "Int32", "Int64", "UInt", "UInt8", "UInt16", "UInt32", "UInt64": return ".integer()" - case "Float", "Double": + case "Float": + return ".float()" + case "Double": return ".double()" case "Bool": return ".boolean()" From d215a06439bba72ed4a945f1d8f22f4a6686e897 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 14:51:53 -0800 Subject: [PATCH 05/12] review checkpoint --- .../GenerativeModel+GenerateObject.swift | 22 +++++++++-- .../Public/Schema+FirebaseGenerable.swift | 10 ++++- .../GenerateContentIntegrationTests.swift | 37 ++++++++++++++++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift index f2e3d7249b9..2ee83bd0642 100644 --- a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift +++ b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift @@ -29,15 +29,29 @@ public extension GenerativeModel { /// the response cannot be decoded into the specified type. func generateObject(as type: T.Type, from prompt: String) async throws -> T { + // Create a new generation config, inheriting previous settings and overriding for JSON output. + let newGenerationConfig = GenerationConfig( + temperature: generationConfig?.temperature, + topP: generationConfig?.topP, + topK: generationConfig?.topK, + candidateCount: generationConfig?.candidateCount, + maxOutputTokens: generationConfig?.maxOutputTokens, + presencePenalty: generationConfig?.presencePenalty, + frequencyPenalty: generationConfig?.frequencyPenalty, + stopSequences: generationConfig?.stopSequences, + responseMIMEType: "application/json", // Override for JSON output. + responseSchema: T.firebaseGenerationSchema, // Override for JSON output. + responseModalities: generationConfig?.responseModalities, + thinkingConfig: generationConfig?.thinkingConfig + ) + + // Create a new model instance with the overridden config. let model = GenerativeModel( modelName: modelName, modelResourceName: modelResourceName, firebaseInfo: generativeAIService.firebaseInfo, apiConfig: apiConfig, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: T.firebaseGenerationSchema - ), + generationConfig: newGenerationConfig, safetySettings: safetySettings, tools: tools, toolConfig: toolConfig, diff --git a/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift b/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift index 2cc8b026577..417423ca4ca 100644 --- a/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift +++ b/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift @@ -22,6 +22,14 @@ public extension Schema { return type.firebaseGenerationSchema } - return .object(properties: [:]) + fatalError(""" + '\(T.self)' does not conform to 'FirebaseGenerable'. + To generate a schema for a Decodable type, it must be annotated with the '@FirebaseGenerable' macro. + Example: + @FirebaseGenerable + struct MyType: Decodable { + // ... + } + """) } } diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 11315c4a971..cbbf24fa542 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -279,10 +279,45 @@ struct GenerateContentIntegrationTests { ) #expect(dessert.name.lowercased() == expectedResponse.name.lowercased()) - #expect(dessert.ingredients.count >= expectedResponse.ingredients.count) + #expect(dessert.ingredients.count >= 2) #expect(dessert.isDelicious == expectedResponse.isDelicious) } + @Test( + "generateObject inherits parent model's generationConfig", + arguments: InstanceConfig.allConfigs + ) + func generateObject_inheritsGenerationConfig(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + // Set a token limit that is too low for the model to generate a valid JSON object. + generationConfig: GenerationConfig(maxOutputTokens: 1) + ) + + // Expect the call to `generateObject` to fail. If the `maxOutputTokens` setting from the + // parent model's `generationConfig` is correctly inherited, the model's response will be a + // truncated, invalid JSON string, causing the `JSONDecoder` to throw an error. If this test + // fails, it means the configuration was not inherited correctly. + do { + _ = try await model.generateObject( + as: Dessert.self, + from: "Give me a recipe for any dessert." + ) + Issue.record("Function was expected to throw, but it did not.") + } catch let error as GenerateContentError { + switch error { + case let .responseStoppedEarly(reason: finishReason, response: _): + #expect(finishReason == .maxTokens) + // Success: Caught the expected responseStoppedEarly error with maxTokens reason. + default: + Issue.record("Caught GenerateContentError, but it was not a responseStoppedEarly error with maxTokens reason.") + } + } catch { + Issue.record("Function threw an unexpected error type: \(error)") + } + } + + @Test( arguments: [ (.vertexAI_v1beta, ModelNames.gemini2_5_Flash, ThinkingConfig(thinkingBudget: 0)), From b25933916a749bc1b820f5d330a345dbaa201f57 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 15:20:30 -0800 Subject: [PATCH 06/12] address code-assist review --- FirebaseAI/Sources/Types/Public/Schema.swift | 33 ++++++++++++ .../GenerateContentIntegrationTests.swift | 43 ++++++++++++--- .../FirebaseGenerableMacro.swift | 20 +++---- .../FirebaseAILogicMacroTests.swift | 52 +++++++++++++++++++ Package.swift | 8 +++ 5 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift diff --git a/FirebaseAI/Sources/Types/Public/Schema.swift b/FirebaseAI/Sources/Types/Public/Schema.swift index b8b2ba6c16e..db19983adce 100644 --- a/FirebaseAI/Sources/Types/Public/Schema.swift +++ b/FirebaseAI/Sources/Types/Public/Schema.swift @@ -481,3 +481,36 @@ extension Schema: Encodable { case propertyOrdering } } + +// MARK: - Helpers + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public extension Schema { + /// Returns a new schema that is identical to the receiver, but with the `nullable` + /// property set to `true`. + /// + /// This is useful for representing optional types. For example, if you have a schema + /// for a `User` object, you can represent an optional `User?` by calling + /// `userSchema.nullable()`. + /// + /// - Returns: A new `Schema` instance with `nullable` set to `true`. + func asNullable() -> Schema { + return Schema( + type: dataType, + format: format, + description: description, + title: title, + nullable: true, + enumValues: enumValues, + items: items, + minItems: minItems, + maxItems: maxItems, + minimum: minimum, + maximum: maximum, + anyOf: anyOf, + properties: properties, + requiredProperties: requiredProperties, + propertyOrdering: propertyOrdering + ) + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index cbbf24fa542..20d35192509 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -147,7 +147,7 @@ struct GenerateContentIntegrationTests { let isDelicious: Bool } - struct Ingredient: Codable { + struct Ingredient: Codable, Equatable { let name: String let quantity: Int } @@ -208,13 +208,13 @@ struct GenerateContentIntegrationTests { } @FirebaseGenerable - struct Ingredient: Codable { + struct Ingredient: Codable, Equatable { let name: String let quantity: Int } @FirebaseGenerable - struct Dessert: Codable { + struct Dessert: Codable, Equatable { let name: String let ingredients: [Ingredient] let isDelicious: Bool @@ -308,15 +308,42 @@ struct GenerateContentIntegrationTests { switch error { case let .responseStoppedEarly(reason: finishReason, response: _): #expect(finishReason == .maxTokens) - // Success: Caught the expected responseStoppedEarly error with maxTokens reason. + // Success: Caught the expected responseStoppedEarly error with maxTokens reason. default: - Issue.record("Caught GenerateContentError, but it was not a responseStoppedEarly error with maxTokens reason.") + Issue + .record( + "Caught GenerateContentError, but it was not a responseStoppedEarly error with maxTokens reason." + ) } - } catch { - Issue.record("Function threw an unexpected error type: \(error)") - } + } catch {} + } + + // A dessert with optional properties for testing nullable schema generation. + @FirebaseGenerable + struct OptionalDessert: Codable, Equatable { + let name: String? + let ingredients: [Ingredient]? + let isDelicious: Bool } + @Test( + "generateObject with optional properties", + arguments: InstanceConfig.allConfigs + ) + func generateObject_withOptionalProperties_succeeds(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite + ) + + let dessert = try await model.generateObject( + as: OptionalDessert.self, + from: "Generate a dessert that is delicious, but you don't know its name or ingredients." + ) + + #expect(dessert.name == nil) + #expect(dessert.ingredients == nil) + #expect(dessert.isDelicious == true) + } @Test( arguments: [ diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index fb2377a2343..eb959fb4438 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -74,29 +74,31 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { } private static func schema(for type: TypeSyntax) throws -> String { + let schemaPrefix = "FirebaseAILogic.Schema" if let type = type.as(IdentifierTypeSyntax.self) { switch type.name.text { case "String": - return ".string()" + return "\(schemaPrefix).string()" case "Int", "Int8", "Int16", "Int32", "Int64", "UInt", "UInt8", "UInt16", "UInt32", "UInt64": return ".integer()" case "Float": - return ".float()" + return "\(schemaPrefix).float()" case "Double": - return ".double()" + return "\(schemaPrefix).double()" case "Bool": - return ".boolean()" + return "\(schemaPrefix).boolean()" default: - return """ - .from(\(type).self) - """ + // For a custom type, generate a call to its static schema property. + return "\(type).firebaseGenerationSchema" } } else if let type = type.as(OptionalTypeSyntax.self) { - return try schema(for: type.wrappedType) + // For an optional type, get the wrapped type's schema and make it nullable. + let wrappedSchema = try schema(for: type.wrappedType) + return "(\(wrappedSchema)).asNullable()" } else if let type = type.as(ArrayTypeSyntax.self) { return try """ - .array(items: \(schema(for: type.element))) + \(schemaPrefix).array(items: \(schema(for: type.element))) """ } diff --git a/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift new file mode 100644 index 00000000000..a3ee62d401b --- /dev/null +++ b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift @@ -0,0 +1,52 @@ +// 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 SwiftSyntaxMacrosTestSupport +import XCTest + +@testable import FirebaseAILogicMacro + +final class FirebaseGenerableMacroTests: XCTestCase { + private let macros = ["FirebaseGenerable": FirebaseGenerableMacro.self] + + func testExpansion_handlesOptionalAndCustomTypes() { + let originalSource = """ + @FirebaseGenerable + struct MyDessert: Decodable { + let name: String? + let ingredients: [Ingredient]? + let isDelicious: Bool + } + """ + + let expectedSource = """ + struct MyDessert: Decodable { + let name: String? + let ingredients: [Ingredient]? + let isDelicious: Bool + public static var firebaseGenerationSchema: FirebaseAILogic.Schema { + .object(properties: [ + "name": (FirebaseAILogic.Schema.string()).asNullable(), + "ingredients": (FirebaseAILogic.Schema.array(items: Ingredient.firebaseGenerationSchema)).asNullable(), + "isDelicious": FirebaseAILogic.Schema.boolean() + ]) + } + } + + extension MyDessert: FirebaseAILogic.FirebaseGenerable {} + """ + + assertMacroExpansion(originalSource, expandedSource: expectedSource, macros: macros) + } +} diff --git a/Package.swift b/Package.swift index f51d0a7e5a6..e18e504ed1b 100644 --- a/Package.swift +++ b/Package.swift @@ -199,6 +199,14 @@ let package = Package( ], path: "FirebaseAILogicMacros/Sources/FirebaseAILogicMacro" ), + .testTarget( + name: "FirebaseAILogicMacroTests", + dependencies: [ + "FirebaseAILogicMacro", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ], + path: "FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests" + ), .target( name: "FirebaseAILogic", dependencies: [ From f56a133ad58a85df5ef1915286f8b26c5bcb9fcb Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 15:33:00 -0800 Subject: [PATCH 07/12] review checkpoint --- FirebaseAI/Sources/GenerationConfig.swift | 22 +++++++++++++++++++ .../GenerativeModel+GenerateObject.swift | 15 +++---------- .../FirebaseGenerableMacro.swift | 2 +- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index fe2b6963e22..32fc1551053 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -203,6 +203,28 @@ public struct GenerationConfig: Sendable { self.responseModalities = responseModalities self.thinkingConfig = thinkingConfig } + + /// Internal initializer to create a new config by overriding specific values of another. + internal init( + from base: GenerationConfig?, + responseMIMEType: String, + responseSchema: Schema + ) { + self.init( + temperature: base?.temperature, + topP: base?.topP, + topK: base?.topK, + candidateCount: base?.candidateCount, + maxOutputTokens: base?.maxOutputTokens, + presencePenalty: base?.presencePenalty, + frequencyPenalty: base?.frequencyPenalty, + stopSequences: base?.stopSequences, + responseMIMEType: responseMIMEType, + responseSchema: responseSchema, + responseModalities: base?.responseModalities, + thinkingConfig: base?.thinkingConfig + ) + } } // MARK: - Codable Conformances diff --git a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift index 2ee83bd0642..95eec776819 100644 --- a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift +++ b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift @@ -31,18 +31,9 @@ public extension GenerativeModel { from prompt: String) async throws -> T { // Create a new generation config, inheriting previous settings and overriding for JSON output. let newGenerationConfig = GenerationConfig( - temperature: generationConfig?.temperature, - topP: generationConfig?.topP, - topK: generationConfig?.topK, - candidateCount: generationConfig?.candidateCount, - maxOutputTokens: generationConfig?.maxOutputTokens, - presencePenalty: generationConfig?.presencePenalty, - frequencyPenalty: generationConfig?.frequencyPenalty, - stopSequences: generationConfig?.stopSequences, - responseMIMEType: "application/json", // Override for JSON output. - responseSchema: T.firebaseGenerationSchema, // Override for JSON output. - responseModalities: generationConfig?.responseModalities, - thinkingConfig: generationConfig?.thinkingConfig + from: generationConfig, + responseMIMEType: "application/json", + responseSchema: T.firebaseGenerationSchema ) // Create a new model instance with the overridden config. diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index eb959fb4438..d3e57c391ad 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -81,7 +81,7 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { return "\(schemaPrefix).string()" case "Int", "Int8", "Int16", "Int32", "Int64", "UInt", "UInt8", "UInt16", "UInt32", "UInt64": - return ".integer()" + return "\(schemaPrefix).integer()" case "Float": return "\(schemaPrefix).float()" case "Double": From f931ce666f40c1437a1ded62d024f79984f9603c Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 15:39:22 -0800 Subject: [PATCH 08/12] more review feedback --- FirebaseAI/Sources/Types/Public/Schema.swift | 44 ++++++++++--------- .../GenerateContentIntegrationTests.swift | 10 ++--- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Schema.swift b/FirebaseAI/Sources/Types/Public/Schema.swift index db19983adce..ab67d2507a8 100644 --- a/FirebaseAI/Sources/Types/Public/Schema.swift +++ b/FirebaseAI/Sources/Types/Public/Schema.swift @@ -155,6 +155,27 @@ public final class Schema: Sendable { self.propertyOrdering = propertyOrdering } + /// Private initializer to create a new schema by copying an existing one with specific overrides. + private convenience init(copying other: Schema, nullable: Bool? = nil) { + self.init( + type: other.dataType, + format: other.format, + description: other.description, + title: other.title, + nullable: nullable ?? other.nullable, + enumValues: other.enumValues, + items: other.items, + minItems: other.minItems, + maxItems: other.maxItems, + minimum: other.minimum, + maximum: other.maximum, + anyOf: other.anyOf, + properties: other.properties, + requiredProperties: other.requiredProperties, + propertyOrdering: other.propertyOrdering + ) + } + /// Returns a `Schema` representing a string value. /// /// This schema instructs the model to produce data of type `"STRING"`, which is suitable for @@ -494,23 +515,6 @@ public extension Schema { /// `userSchema.nullable()`. /// /// - Returns: A new `Schema` instance with `nullable` set to `true`. - func asNullable() -> Schema { - return Schema( - type: dataType, - format: format, - description: description, - title: title, - nullable: true, - enumValues: enumValues, - items: items, - minItems: minItems, - maxItems: maxItems, - minimum: minimum, - maximum: maximum, - anyOf: anyOf, - properties: properties, - requiredProperties: requiredProperties, - propertyOrdering: propertyOrdering - ) - } -} + func asNullable() -> Schema { + return Schema(copying: self, nullable: true) + }} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 20d35192509..8be2eb0f452 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -143,11 +143,11 @@ struct GenerateContentIntegrationTests { func generateContentJSONObject(_ config: InstanceConfig) async throws { struct Recipe: Codable { let name: String - let ingredients: [Ingredient] + let ingredients: [RecipeIngredient] let isDelicious: Bool } - struct Ingredient: Codable, Equatable { + struct RecipeIngredient: Codable, Equatable { let name: String let quantity: Int } @@ -155,9 +155,9 @@ struct GenerateContentIntegrationTests { let expectedResponse = Recipe( name: "Apple Pie", ingredients: [ - Ingredient(name: "Apple", quantity: 6), - Ingredient(name: "Cinnamon", quantity: 1), - Ingredient(name: "Sugar", quantity: 1), + RecipeIngredient(name: "Apple", quantity: 6), + RecipeIngredient(name: "Cinnamon", quantity: 1), + RecipeIngredient(name: "Sugar", quantity: 1), ], isDelicious: true ) From c7e86f1d24442775217d6043218dd59c2e7fb618 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 16:05:26 -0800 Subject: [PATCH 09/12] review --- FirebaseAI/Sources/GenerationConfig.swift | 8 +-- FirebaseAI/Sources/GenerativeAIService.swift | 2 +- .../GenerativeModel+GenerateObject.swift | 13 +---- FirebaseAI/Sources/GenerativeModel.swift | 18 ++++++ FirebaseAI/Sources/Types/Public/Schema.swift | 7 ++- .../GenerateContentIntegrationTests.swift | 4 +- .../FirebaseGenerableMacro.swift | 58 ++++++++++--------- .../FirebaseAILogicMacroTests.swift | 28 +++++++++ 8 files changed, 90 insertions(+), 48 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 32fc1551053..d8249260f55 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -205,11 +205,9 @@ public struct GenerationConfig: Sendable { } /// Internal initializer to create a new config by overriding specific values of another. - internal init( - from base: GenerationConfig?, - responseMIMEType: String, - responseSchema: Schema - ) { + init(from base: GenerationConfig?, + responseMIMEType: String, + responseSchema: Schema) { self.init( temperature: base?.temperature, topP: base?.topP, diff --git a/FirebaseAI/Sources/GenerativeAIService.swift b/FirebaseAI/Sources/GenerativeAIService.swift index 8591980cf86..87aed9c8db5 100644 --- a/FirebaseAI/Sources/GenerativeAIService.swift +++ b/FirebaseAI/Sources/GenerativeAIService.swift @@ -28,7 +28,7 @@ struct GenerativeAIService { let firebaseInfo: FirebaseInfo - private let urlSession: URLSession + let urlSession: URLSession init(firebaseInfo: FirebaseInfo, urlSession: URLSession) { self.firebaseInfo = firebaseInfo diff --git a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift index 95eec776819..f296cb0aa52 100644 --- a/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift +++ b/FirebaseAI/Sources/GenerativeModel+GenerateObject.swift @@ -37,18 +37,7 @@ public extension GenerativeModel { ) // Create a new model instance with the overridden config. - let model = GenerativeModel( - modelName: modelName, - modelResourceName: modelResourceName, - firebaseInfo: generativeAIService.firebaseInfo, - apiConfig: apiConfig, - generationConfig: newGenerationConfig, - safetySettings: safetySettings, - tools: tools, - toolConfig: toolConfig, - systemInstruction: systemInstruction, - requestOptions: requestOptions - ) + let model = GenerativeModel(copying: self, generationConfig: newGenerationConfig) let response = try await model.generateContent(prompt) guard let text = response.text, let data = text.data(using: .utf8) else { diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index cadb2728c70..10b3af4f5e5 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -118,6 +118,24 @@ public final class GenerativeModel: Sendable { AILog.debug(code: .generativeModelInitialized, "Model \(modelResourceName) initialized.") } + /// Internal initializer to create a new model by copying an existing one with specific overrides. + convenience init(copying other: GenerativeModel, + generationConfig: GenerationConfig? = nil) { + self.init( + modelName: other.modelName, + modelResourceName: other.modelResourceName, + firebaseInfo: other.generativeAIService.firebaseInfo, + apiConfig: other.apiConfig, + generationConfig: generationConfig ?? other.generationConfig, + safetySettings: other.safetySettings, + tools: other.tools, + toolConfig: other.toolConfig, + systemInstruction: other.systemInstruction, + requestOptions: other.requestOptions, + urlSession: other.generativeAIService.urlSession + ) + } + /// Generates content from String and/or image inputs, given to the model as a prompt, that are /// representable as one or more ``Part``s. /// diff --git a/FirebaseAI/Sources/Types/Public/Schema.swift b/FirebaseAI/Sources/Types/Public/Schema.swift index ab67d2507a8..8d0cc392006 100644 --- a/FirebaseAI/Sources/Types/Public/Schema.swift +++ b/FirebaseAI/Sources/Types/Public/Schema.swift @@ -515,6 +515,7 @@ public extension Schema { /// `userSchema.nullable()`. /// /// - Returns: A new `Schema` instance with `nullable` set to `true`. - func asNullable() -> Schema { - return Schema(copying: self, nullable: true) - }} + func asNullable() -> Schema { + return Schema(copying: self, nullable: true) + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 8be2eb0f452..a24116b539a 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -315,7 +315,9 @@ struct GenerateContentIntegrationTests { "Caught GenerateContentError, but it was not a responseStoppedEarly error with maxTokens reason." ) } - } catch {} + } catch { + Issue.record("Function threw an unexpected error type: \(error)") + } } // A dessert with optional properties for testing nullable schema generation. diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index d3e57c391ad..c4939cd5b82 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -24,15 +24,34 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { -> [SwiftSyntax.DeclSyntax] where Declaration: SwiftSyntax.DeclGroupSyntax, Context: SwiftSyntaxMacros.MacroExpansionContext { - let properties = declaration.memberBlock.members.compactMap { - $0.decl.as(VariableDeclSyntax.self) - } + // 1. Get all variable declarations from the member block. + let varDecls = declaration.memberBlock.members + .compactMap { $0.decl.as(VariableDeclSyntax.self) } - let schemaProperties: [String] = try properties.map { property in - let (name, type) = try property.toNameAndType() - return try """ - "\(name)": \(schema(for: type)) - """ + // 2. Process each declaration to extract schema properties from its bindings. + let schemaProperties = try varDecls.flatMap { varDecl -> [String] in + // For declarations like `let a, b: String`, the type annotation is on the last binding. + let typeAnnotationFromDecl = varDecl.bindings.last?.typeAnnotation + + return try varDecl.bindings.compactMap { binding -> String? in + // 3. Filter out computed properties. Stored properties do not have a getter/setter block. + guard binding.accessorBlock == nil else { + return nil + } + + // 4. Get the property's name. Skip complex patterns like tuples. + guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { + return nil + } + + // 5. Determine the property's type. It can be on the binding itself or on the declaration. + guard let type = binding.typeAnnotation?.type ?? typeAnnotationFromDecl?.type else { + throw MacroError.missingExplicitType(for: name) + } + + // 6. Generate the schema string for this property. + return try "\"\(name)\": \(schema(for: type))" + } } return [ @@ -106,29 +125,16 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { } } -private extension VariableDeclSyntax { - func toNameAndType() throws -> (String, TypeSyntax) { - guard let binding = bindings.first else { - throw MacroError.unsupportedType(Syntax(self)) - } - guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { - throw MacroError.unsupportedType(Syntax(self)) - } - guard let type = binding.typeAnnotation?.type else { - throw MacroError.unsupportedType(Syntax(self)) - } - - return (name, type) - } -} - private enum MacroError: Error, CustomStringConvertible { case unsupportedType(Syntax) + case missingExplicitType(for: String) var description: String { switch self { - case let .unsupportedType(type): - return "Unsupported type: \(type)" + case let .unsupportedType(syntax): + return "Unsupported type syntax: \(syntax)" + case let .missingExplicitType(name): + return "Property '\(name)' must have an explicit type annotation to be used with @FirebaseGenerable." } } } diff --git a/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift index a3ee62d401b..de58caccb46 100644 --- a/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift +++ b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift @@ -47,6 +47,34 @@ final class FirebaseGenerableMacroTests: XCTestCase { extension MyDessert: FirebaseAILogic.FirebaseGenerable {} """ + assertMacroExpansion(originalSource, expandedSource: expectedSource, macros: macros) + func testExpansion_handlesMultiBindingAndComputedProperties() { + let originalSource = """ + @FirebaseGenerable + struct MyType: Decodable { + let a, b: String + let c: Int + var isComputed: Bool { return true } + } + """ + + let expectedSource = """ + struct MyType: Decodable { + let a, b: String + let c: Int + var isComputed: Bool { return true } + public static var firebaseGenerationSchema: FirebaseAILogic.Schema { + .object(properties: [ + "a": FirebaseAILogic.Schema.string(), + "b": FirebaseAILogic.Schema.string(), + "c": FirebaseAILogic.Schema.integer() + ]) + } + } + + extension MyType: FirebaseAILogic.FirebaseGenerable {} + """ + assertMacroExpansion(originalSource, expandedSource: expectedSource, macros: macros) } } From 6587027740f6c70f2f03f1618852c26e74320124 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 16:27:58 -0800 Subject: [PATCH 10/12] review --- .../Public/Schema+FirebaseGenerable.swift | 35 ------------------- .../GenerateContentIntegrationTests.swift | 8 +++-- .../FirebaseAILogicMacroTests.swift | 2 ++ 3 files changed, 7 insertions(+), 38 deletions(-) delete mode 100644 FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift diff --git a/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift b/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift deleted file mode 100644 index 417423ca4ca..00000000000 --- a/FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift +++ /dev/null @@ -1,35 +0,0 @@ -// 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 Schema { - /// Returns a `Schema` representing a `Decodable` type. - static func from(_ type: T.Type) -> Schema { - if let type = type as? FirebaseGenerable.Type { - return type.firebaseGenerationSchema - } - - fatalError(""" - '\(T.self)' does not conform to 'FirebaseGenerable'. - To generate a schema for a Decodable type, it must be annotated with the '@FirebaseGenerable' macro. - Example: - @FirebaseGenerable - struct MyType: Decodable { - // ... - } - """) - } -} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index a24116b539a..24c51fc7dcc 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -295,9 +295,11 @@ struct GenerateContentIntegrationTests { ) // Expect the call to `generateObject` to fail. If the `maxOutputTokens` setting from the - // parent model's `generationConfig` is correctly inherited, the model's response will be a - // truncated, invalid JSON string, causing the `JSONDecoder` to throw an error. If this test - // fails, it means the configuration was not inherited correctly. + // parent model's `generationConfig` is correctly inherited, the model's response will be + // truncated + // (due to `maxOutputTokens`), causing a `.responseStoppedEarly` error with a `.maxTokens` + // reason + // to be thrown. If this test fails, it means the configuration was not inherited correctly. do { _ = try await model.generateObject( as: Dessert.self, diff --git a/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift index de58caccb46..1e8d8e8e8fb 100644 --- a/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift +++ b/FirebaseAILogicMacros/Tests/FirebaseAILogicMacroTests/FirebaseAILogicMacroTests.swift @@ -48,6 +48,8 @@ final class FirebaseGenerableMacroTests: XCTestCase { """ assertMacroExpansion(originalSource, expandedSource: expectedSource, macros: macros) + } + func testExpansion_handlesMultiBindingAndComputedProperties() { let originalSource = """ @FirebaseGenerable From e35d69094e179697a42cb90d0df07a95677f986a Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 16:36:29 -0800 Subject: [PATCH 11/12] review --- .../FirebaseGenerableMacro.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index c4939cd5b82..830d2a2ac15 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -71,24 +71,22 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { conformingTo protocols: [SwiftSyntax.TypeSyntax], in context: some SwiftSyntaxMacros .MacroExpansionContext) throws - -> [SwiftSyntax.ExtensionDeclSyntax] { + -> [SwiftSyntax.ExtensionDeclSyntax] { if protocols.isEmpty { return [] } - + let inheritanceClause = InheritanceClauseSyntax { - for protocolType in protocols { - InheritedTypeSyntax(type: protocolType) - } + InheritedTypeSyntax(type: TypeSyntax("FirebaseAILogic.FirebaseGenerable")) } - + let extensionDecl = ExtensionDeclSyntax( extendedType: type.trimmed, inheritanceClause: inheritanceClause ) { // Empty member block } - + return [extensionDecl] } From 4ae79331a548108659bff34e2bf5916738c193b7 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Sat, 22 Nov 2025 16:55:56 -0800 Subject: [PATCH 12/12] style --- .../FirebaseAILogicMacro/FirebaseGenerableMacro.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift index 830d2a2ac15..6dccb1bc8be 100644 --- a/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift +++ b/FirebaseAILogicMacros/Sources/FirebaseAILogicMacro/FirebaseGenerableMacro.swift @@ -71,22 +71,22 @@ public struct FirebaseGenerableMacro: MemberMacro, ExtensionMacro { conformingTo protocols: [SwiftSyntax.TypeSyntax], in context: some SwiftSyntaxMacros .MacroExpansionContext) throws - -> [SwiftSyntax.ExtensionDeclSyntax] { + -> [SwiftSyntax.ExtensionDeclSyntax] { if protocols.isEmpty { return [] } - + let inheritanceClause = InheritanceClauseSyntax { InheritedTypeSyntax(type: TypeSyntax("FirebaseAILogic.FirebaseGenerable")) } - + let extensionDecl = ExtensionDeclSyntax( extendedType: type.trimmed, inheritanceClause: inheritanceClause ) { // Empty member block } - + return [extensionDecl] }