Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 61 additions & 13 deletions FirebaseAI/Sources/GenerationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down
84 changes: 46 additions & 38 deletions FirebaseAI/Sources/GenerativeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions FirebaseAI/Sources/GenerativeModelSession.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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<GeneratedContent> {
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<Content>(to prompt: PartsRepresentable...,
generating type: Content.Type = Content.self,
includeSchemaInPrompt: Bool = true,
options: GenerationConfig? = nil) async throws
-> GenerativeModelSession.Response<Content> 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<Content> 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)
Loading
Loading