diff --git a/FirebaseAI/Sources/Extensions/Internal/GenerationSchema+Gemini.swift b/FirebaseAI/Sources/Extensions/Internal/GenerationSchema+Gemini.swift new file mode 100644 index 00000000000..5b31acbc1c1 --- /dev/null +++ b/FirebaseAI/Sources/Extensions/Internal/GenerationSchema+Gemini.swift @@ -0,0 +1,34 @@ +// Copyright 2026 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 { + /// Returns a Gemini-compatible JSON Schema of this `GenerationSchema`. + func toGeminiJSONSchema() throws -> JSONObject { + let generationSchemaData = try JSONEncoder().encode(self) + var jsonSchema = try JSONDecoder().decode(JSONObject.self, from: generationSchemaData) + if let propertyOrdering = jsonSchema.removeValue(forKey: "x-order") { + jsonSchema["propertyOrdering"] = propertyOrdering + } + + return jsonSchema + } + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index fe2b6963e22..c4f7f91be5b 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -19,45 +19,45 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GenerationConfig: Sendable { /// Controls the degree of randomness in token selection. - let temperature: Float? + var temperature: Float? /// Controls diversity of generated text. - let topP: Float? + var topP: Float? /// Limits the number of highest probability words considered. - let topK: Int? + var 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? + var maxOutputTokens: Int? /// Controls the likelihood of repeating the same words or phrases already generated in the text. - let presencePenalty: Float? + var presencePenalty: Float? /// Controls the likelihood of repeating words, with the penalty increasing for each repetition. - let frequencyPenalty: Float? + var frequencyPenalty: Float? /// A set of up to 5 `String`s that will stop output generation. - let stopSequences: [String]? + var 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? + var thinkingConfig: ThinkingConfig? /// Creates a new `GenerationConfig` value. /// @@ -203,6 +203,54 @@ public struct GenerationConfig: Sendable { self.responseModalities = responseModalities self.thinkingConfig = thinkingConfig } + + /// Merges two configurations, giving precedence to values found in the `overrides` parameter. + /// + /// - Parameters: + /// - base: The foundational configuration (e.g., model-level defaults). + /// - overrides: The configuration containing values that should supersede the base (e.g., + /// request-level specific settings). + /// - Returns: A merged `GenerationConfig` prioritizing `overrides`, or `nil` if both inputs are + /// `nil`. + static func merge(_ base: GenerationConfig?, + with overrides: GenerationConfig?) -> GenerationConfig? { + // 1. If the base config is missing, return the overrides (which might be nil). + guard let baseConfig = base else { + return overrides + } + + // 2. If overrides are missing, strictly return the base. + guard let overrideConfig = overrides else { + return baseConfig + } + + // 3. Start with a copy of the base config. + var config = baseConfig + + // 4. Overwrite with any non-nil values found in the overrides. + config.temperature = overrideConfig.temperature ?? config.temperature + config.topP = overrideConfig.topP ?? config.topP + config.topK = overrideConfig.topK ?? config.topK + config.candidateCount = overrideConfig.candidateCount ?? config.candidateCount + config.maxOutputTokens = overrideConfig.maxOutputTokens ?? config.maxOutputTokens + config.presencePenalty = overrideConfig.presencePenalty ?? config.presencePenalty + config.frequencyPenalty = overrideConfig.frequencyPenalty ?? config.frequencyPenalty + config.stopSequences = overrideConfig.stopSequences ?? config.stopSequences + config.responseMIMEType = overrideConfig.responseMIMEType ?? config.responseMIMEType + config.responseModalities = overrideConfig.responseModalities ?? config.responseModalities + config.thinkingConfig = overrideConfig.thinkingConfig ?? config.thinkingConfig + + // 5. Handle Schema mutual exclusivity with precedence for `responseFirebaseGenerationSchema`. + if let responseJSONSchema = overrideConfig.responseJSONSchema { + config.responseJSONSchema = responseJSONSchema + config.responseSchema = nil + } else if let responseSchema = overrideConfig.responseSchema { + config.responseSchema = responseSchema + config.responseJSONSchema = nil + } + + return config + } } // MARK: - Codable Conformances diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index cadb2728c70..82cf02980d5 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -144,44 +144,7 @@ public final class GenerativeModel: Sendable { /// - Throws: A ``GenerateContentError`` if the request failed. public func generateContent(_ content: [ModelContent]) async throws -> GenerateContentResponse { - try content.throwIfError() - let response: GenerateContentResponse - let generateContentRequest = GenerateContentRequest( - model: modelResourceName, - contents: content, - generationConfig: generationConfig, - safetySettings: safetySettings, - tools: tools, - toolConfig: toolConfig, - systemInstruction: systemInstruction, - apiConfig: apiConfig, - apiMethod: .generateContent, - options: requestOptions - ) - do { - response = try await generativeAIService.loadRequest(request: generateContentRequest) - } catch { - throw GenerativeModel.generateContentError(from: error) - } - - // Check the prompt feedback to see if the prompt was blocked. - if response.promptFeedback?.blockReason != nil { - throw GenerateContentError.promptBlocked(response: response) - } - - // Check to see if an error should be thrown for stop reason. - if let reason = response.candidates.first?.finishReason, reason != .stop { - throw GenerateContentError.responseStoppedEarly(reason: reason, response: response) - } - - // If all candidates are empty (contain no information that a developer could act on) then throw - if response.candidates.allSatisfy({ $0.isEmpty }) { - throw GenerateContentError.internalError(underlying: InvalidCandidateError.emptyContent( - underlyingError: Candidate.EmptyContentError() - )) - } - - return response + return try await generateContent(content, generationConfig: generationConfig) } /// Generates content from String and/or image inputs, given to the model as a prompt, that are @@ -357,6 +320,51 @@ public final class GenerativeModel: Sendable { return try await generativeAIService.loadRequest(request: countTokensRequest) } + // MARK: - Internal + + public func generateContent(_ content: [ModelContent], + generationConfig: GenerationConfig?) async throws + -> GenerateContentResponse { + try content.throwIfError() + let response: GenerateContentResponse + let generateContentRequest = GenerateContentRequest( + model: modelResourceName, + contents: content, + generationConfig: generationConfig, + safetySettings: safetySettings, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + apiConfig: apiConfig, + apiMethod: .generateContent, + options: requestOptions + ) + do { + response = try await generativeAIService.loadRequest(request: generateContentRequest) + } catch { + throw GenerativeModel.generateContentError(from: error) + } + + // Check the prompt feedback to see if the prompt was blocked. + if response.promptFeedback?.blockReason != nil { + throw GenerateContentError.promptBlocked(response: response) + } + + // Check to see if an error should be thrown for stop reason. + if let reason = response.candidates.first?.finishReason, reason != .stop { + throw GenerateContentError.responseStoppedEarly(reason: reason, response: response) + } + + // If all candidates are empty (contain no information that a developer could act on) then throw + if response.candidates.allSatisfy({ $0.isEmpty }) { + throw GenerateContentError.internalError(underlying: InvalidCandidateError.emptyContent( + underlyingError: Candidate.EmptyContentError() + )) + } + + return response + } + /// Returns a `GenerateContentError` (for public consumption) from an internal error. /// /// If `error` is already a `GenerateContentError` the error is returned unchanged. diff --git a/FirebaseAI/Sources/GenerativeModelSession.swift b/FirebaseAI/Sources/GenerativeModelSession.swift new file mode 100644 index 00000000000..cb9042c8dc0 --- /dev/null +++ b/FirebaseAI/Sources/GenerativeModelSession.swift @@ -0,0 +1,122 @@ +// Copyright 2026 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. + +// TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. +#if compiler(>=6.2) && canImport(FoundationModels) + import Foundation + import FoundationModels + + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public final class GenerativeModelSession: Sendable { + let generativeModel: GenerativeModel + + public init(model: GenerativeModel) { + generativeModel = model + } + + @discardableResult + public final nonisolated(nonsending) + func respond(to prompt: PartsRepresentable..., options: GenerationConfig? = nil) async throws + -> GenerativeModelSession.Response { + let parts = [ModelContent(parts: prompt)] + + var config = GenerationConfig.merge( + generativeModel.generationConfig, with: options + ) ?? GenerationConfig() + config.responseModalities = nil // Override to the default (text only) + config.candidateCount = nil // Override to the default (one candidate) + + let response = try await generativeModel.generateContent(parts, generationConfig: config) + guard let text = response.text else { + throw GenerationError.decodingFailure( + GenerationError.Context(debugDescription: "No text in response: \(response)") + ) + } + let generatedContent = GeneratedContent(kind: .string(text)) + + return GenerativeModelSession.Response( + content: text, rawContent: generatedContent, rawResponse: response + ) + } + + @discardableResult + public final nonisolated(nonsending) + func respond(to prompt: PartsRepresentable..., schema: GenerationSchema, + includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) async throws + -> GenerativeModelSession.Response { + let parts = [ModelContent(parts: prompt)] + var config = GenerationConfig.merge( + generativeModel.generationConfig, with: options + ) ?? GenerationConfig() + config.responseMIMEType = "application/json" + config.responseJSONSchema = includeSchemaInPrompt ? try schema.toGeminiJSONSchema() : nil + config.responseSchema = nil // `responseSchema` must not be set with `responseJSONSchema` + config.responseModalities = nil // Override to the default (text only) + config.candidateCount = nil // Override to the default (one candidate) + + let response = try await generativeModel.generateContent(parts, generationConfig: config) + guard let text = response.text else { + throw GenerationError.decodingFailure( + GenerationError.Context(debugDescription: "No text in response: \(response)") + ) + } + let generatedContent = try GeneratedContent(json: text) + + return GenerativeModelSession.Response( + content: generatedContent, rawContent: generatedContent, rawResponse: response + ) + } + + @discardableResult + public final nonisolated(nonsending) + func respond(to prompt: PartsRepresentable..., + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) async throws + -> GenerativeModelSession.Response where Content: Generable { + let response = try await respond( + to: prompt, + schema: type.generationSchema, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + + let content = try Content(response.rawContent) + + return GenerativeModelSession.Response( + content: content, rawContent: response.rawContent, rawResponse: response.rawResponse + ) + } + + public struct Response where Content: Generable { + public let content: Content + public let rawContent: GeneratedContent + public let rawResponse: GenerateContentResponse + } + + public enum GenerationError: Error, LocalizedError { + public struct Context: Sendable { + public let debugDescription: String + + init(debugDescription: String) { + self.debugDescription = debugDescription + } + } + + case decodingFailure(GenerativeModelSession.GenerationError.Context) + } + } +#endif // compiler(>=6.2) && canImport(FoundationModels) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerativeModelSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerativeModelSessionTests.swift new file mode 100644 index 00000000000..16c590e5f37 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerativeModelSessionTests.swift @@ -0,0 +1,101 @@ +// Copyright 2026 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. + +// TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. +#if compiler(>=6.2) && canImport(FoundationModels) + import FirebaseAILogic + import FirebaseAITestApp + import FoundationModels + import Testing + + @Suite(.serialized) + struct GenerativeModelSessionTests { + @Test(arguments: [InstanceConfig.vertexAI_v1beta_global]) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func respondText(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + ) + let session = GenerativeModelSession(model: model) + let prompt = "Why is the sky blue?" + + let response = try await session.respond(to: prompt) + + let content = response.content + #expect(!content.isEmpty) + #expect(response.rawContent.kind == .string(content)) + #expect(response.rawResponse.text == content) + } + + @Generable(description: "Basic profile information about a cat") + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + struct CatProfile { + // A guide isn't necessary for basic fields. + var name: String + + @Guide(description: "The age of the cat", .range(1 ... 20)) + var age: Int + + @Guide(description: "A one sentence profile about the cat's personality") + var profile: String + } + + @Test(arguments: [InstanceConfig.vertexAI_v1beta_global]) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func respondGeneratedContent(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + ) + let session = GenerativeModelSession(model: model) + let prompt = "Generate a cute rescue cat" + + let response = try await session.respond(to: prompt, schema: CatProfile.generationSchema) + + let content = response.content + let name: String = try content.value(forProperty: "name") + #expect(!name.isEmpty) + let age: Int = try content.value(forProperty: "age") + #expect(age >= 1) + #expect(age <= 20) + let profile: String = try content.value(forProperty: "profile") + #expect(!profile.isEmpty) + } + + @Test(arguments: [InstanceConfig.vertexAI_v1beta_global]) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func respondGenerable(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + ) + let session = GenerativeModelSession(model: model) + let prompt = "Generate a Ragdoll kitten" + + let response = try await session.respond(to: prompt, generating: CatProfile.self) + + let catProfile = response.content + #expect(!catProfile.name.isEmpty) + #expect(catProfile.age >= 1) + #expect(catProfile.age <= 20) + #expect(!catProfile.profile.isEmpty) + } + } +#endif // compiler(>=6.2) && canImport(FoundationModels)