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
22 changes: 22 additions & 0 deletions FirebaseAI/Sources/APIMethod.swift
Original file line number Diff line number Diff line change
@@ -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

enum APIMethod: String {
case generateContent
case streamGenerateContent
case countTokens
}
79 changes: 9 additions & 70 deletions FirebaseAI/Sources/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,21 @@ import Foundation
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public final class Chat: Sendable {
private let model: GenerativeModel
private let _history: History

/// Initializes a new chat representing a 1:1 conversation between model and user.
init(model: GenerativeModel, history: [ModelContent]) {
self.model = model
self.history = history
_history = History(history: history)
}

private let historyLock = NSLock()
private nonisolated(unsafe) var _history: [ModelContent] = []
/// The previous content from the chat that has been successfully sent and received from the
/// model. This will be provided to the model for each message sent as context for the discussion.
public var history: [ModelContent] {
get {
historyLock.withLock { _history }
return _history.history
}
set {
historyLock.withLock { _history = newValue }
}
}

private func appendHistory(contentsOf: [ModelContent]) {
historyLock.withLock {
_history.append(contentsOf: contentsOf)
}
}

private func appendHistory(_ newElement: ModelContent) {
historyLock.withLock {
_history.append(newElement)
_history.history = newValue
}
}

Expand Down Expand Up @@ -87,8 +73,8 @@ public final class Chat: Sendable {
let toAdd = ModelContent(role: "model", parts: reply.parts)

// Append the request and successful result to history, then return the value.
appendHistory(contentsOf: newContent)
appendHistory(toAdd)
_history.append(contentsOf: newContent)
_history.append(toAdd)
return result
}

Expand Down Expand Up @@ -136,63 +122,16 @@ public final class Chat: Sendable {
}

// Save the request.
appendHistory(contentsOf: newContent)
_history.append(contentsOf: newContent)

// Aggregate the content to add it to the history before we finish.
let aggregated = self.aggregatedChunks(aggregatedContent)
self.appendHistory(aggregated)
let aggregated = self._history.aggregatedChunks(aggregatedContent)
self._history.append(aggregated)
continuation.finish()
}
}
}

private func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent {
var parts: [InternalPart] = []
var combinedText = ""
var combinedThoughts = ""

func flush() {
if !combinedThoughts.isEmpty {
parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil))
combinedThoughts = ""
}
if !combinedText.isEmpty {
parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil))
combinedText = ""
}
}

// Loop through all the parts, aggregating the text.
for part in chunks.flatMap({ $0.internalParts }) {
// Only text parts may be combined.
if case let .text(text) = part.data, part.thoughtSignature == nil {
// Thought summaries must not be combined with regular text.
if part.isThought ?? false {
// If we were combining regular text, flush it before handling "thoughts".
if !combinedText.isEmpty {
flush()
}
combinedThoughts += text
} else {
// If we were combining "thoughts", flush it before handling regular text.
if !combinedThoughts.isEmpty {
flush()
}
combinedText += text
}
} else {
// This is a non-combinable part (not text), flush any pending text.
flush()
parts.append(part)
}
}

// Flush any remaining text.
flush()

return ModelContent(role: "model", parts: parts)
}

/// Populates the `role` field with `user` if it doesn't exist. Required in chat sessions.
private func populateContentRole(_ content: ModelContent) -> ModelContent {
if content.role != nil {
Expand Down
22 changes: 22 additions & 0 deletions FirebaseAI/Sources/FirebaseAI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,28 @@ public final class FirebaseAI: Sendable {
)
}

/// Initializes a new `TemplateGenerativeModel`.
///
/// - Returns: A new `TemplateGenerativeModel` instance.
public func templateGenerativeModel() -> TemplateGenerativeModel {
return TemplateGenerativeModel(
generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo,
urlSession: GenAIURLSession.default),
apiConfig: apiConfig
)
}

/// Initializes a new `TemplateImagenModel`.
///
/// - Returns: A new `TemplateImagenModel` instance.
public func templateImagenModel() -> TemplateImagenModel {
return TemplateImagenModel(
generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo,
urlSession: GenAIURLSession.default),
apiConfig: apiConfig
)
}

/// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters.
///
/// > Warning: Using the Firebase AI Logic SDKs with the Gemini Live API is in Public
Expand Down
9 changes: 0 additions & 9 deletions FirebaseAI/Sources/GenerateContentRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,6 @@ extension GenerateContentRequest: Encodable {
}
}

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
extension GenerateContentRequest {
enum APIMethod: String {
case generateContent
case streamGenerateContent
case countTokens
}
}

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
extension GenerateContentRequest: GenerativeAIRequest {
typealias Response = GenerateContentResponse
Expand Down
58 changes: 58 additions & 0 deletions FirebaseAI/Sources/GenerateImagesRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@

// 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 class GenerateImagesRequest: @unchecked Sendable, GenerativeAIRequest {
public typealias Response = ImagenGenerationResponse<ImagenInlineImage>

public var url: URL {
var urlString =
"\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/projects/\(projectID)"
if case let .vertexAI(_, location) = apiConfig.service {
urlString += "/locations/\(location)"
}
let templateName = template.hasSuffix(".prompt") ? template : "\(template).prompt"
urlString += "/templates/\(templateName):\(ImageAPIMethod.generateImages.rawValue)"
return URL(string: urlString)!
}

public let options: RequestOptions

let apiConfig: APIConfig

let template: String
let variables: [String: TemplateVariable]
let projectID: String

init(template: String, variables: [String: TemplateVariable], projectID: String,
apiConfig: APIConfig, options: RequestOptions) {
self.apiConfig = apiConfig
self.options = options
self.template = template
self.variables = variables
self.projectID = projectID
}

enum CodingKeys: String, CodingKey {
case variables = "inputs"
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(variables, forKey: .variables)
}
}
2 changes: 1 addition & 1 deletion FirebaseAI/Sources/GenerativeAIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
/// The Firebase SDK version in the format `fire/<version>`.
static let firebaseVersionTag = "fire/\(FirebaseVersion())"

private let firebaseInfo: FirebaseInfo
let firebaseInfo: FirebaseInfo

private let urlSession: URLSession

Expand Down Expand Up @@ -61,7 +61,7 @@
)
}

throw parseError(responseData: data)

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, catalyst)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, catalyst)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, catalyst)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-14, Xcode_16.2, iOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-14, Xcode_16.2, iOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, macOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, macOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, macOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, iOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, visionOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, tvOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, tvOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, watchOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"

Check failure on line 64 in FirebaseAI/Sources/GenerativeAIService.swift

View workflow job for this annotation

GitHub Actions / spm / spm (macos-15, Xcode_16.4, watchOS)

testSendMessage, failed: caught error: "BackendError(httpResponseCode: 400, message: "API key not valid. Please pass a valid API key.", status: FirebaseAI.RPCStatus.invalidArgument, details: [FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.ErrorInfo", reason: Optional("API_KEY_INVALID"), domain: Optional("googleapis.com"), metadata: Optional(["service": "firebasevertexai.googleapis.com"])), FirebaseAI.ErrorDetails(type: "type.googleapis.com/google.rpc.LocalizedMessage", reason: nil, domain: nil, metadata: nil)])"
}

return try parseResponse(T.Response.self, from: data)
Expand Down
94 changes: 94 additions & 0 deletions FirebaseAI/Sources/History.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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, *)
final class History: Sendable {
private let historyLock = NSLock()
private nonisolated(unsafe) var _history: [ModelContent] = []
/// The previous content from the chat that has been successfully sent and received from the
/// model. This will be provided to the model for each message sent as context for the discussion.
public var history: [ModelContent] {
get {
historyLock.withLock { _history }
}
set {
historyLock.withLock { _history = newValue }
}
}

init(history: [ModelContent]) {
self.history = history
}

func append(contentsOf: [ModelContent]) {
historyLock.withLock {
_history.append(contentsOf: contentsOf)
}
}

func append(_ newElement: ModelContent) {
historyLock.withLock {
_history.append(newElement)
}
}

func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent {
var parts: [InternalPart] = []
var combinedText = ""
var combinedThoughts = ""

func flush() {
if !combinedThoughts.isEmpty {
parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil))
combinedThoughts = ""
}
if !combinedText.isEmpty {
parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil))
combinedText = ""
}
}

// Loop through all the parts, aggregating the text.
for part in chunks.flatMap({ $0.internalParts }) {
// Only text parts may be combined.
if case let .text(text) = part.data, part.thoughtSignature == nil {
// Thought summaries must not be combined with regular text.
if part.isThought ?? false {
// If we were combining regular text, flush it before handling "thoughts".
if !combinedText.isEmpty {
flush()
}
combinedThoughts += text
} else {
// If we were combining "thoughts", flush it before handling regular text.
if !combinedThoughts.isEmpty {
flush()
}
combinedText += text
}
} else {
// This is a non-combinable part (not text), flush any pending text.
flush()
parts.append(part)
}
}

// Flush any remaining text.
flush()

return ModelContent(role: "model", parts: parts)
}
}
18 changes: 18 additions & 0 deletions FirebaseAI/Sources/ImageAPIMethod.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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.
//
// 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 ImageAPIMethod: String {
case generateImages = "templatePredict"
}
Loading
Loading