Skip to content

Commit 4f36a1c

Browse files
paulb777google-labs-jules[bot]andrewheardgemini-code-assist[bot]
authored
[AI] Server Prompt Templates (#15402)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Andrew Heard <andrewheard@google.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 37715f0 commit 4f36a1c

24 files changed

+1248
-89
lines changed

FirebaseAI/Sources/AILog.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ enum AILog {
8787
case generateContentResponseEmptyCandidates = 4003
8888
case invalidWebsocketURL = 4004
8989
case duplicateLiveSessionSetupComplete = 4005
90+
case malformedURL = 4006
9091

9192
// SDK Debugging
9293
case loadRequestStreamResponseLine = 5000
@@ -138,6 +139,17 @@ enum AILog {
138139
log(level: .debug, code: code, message)
139140
}
140141

142+
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
143+
static func makeInternalError(message: String, code: MessageCode) -> GenerateContentError {
144+
let error = GenerateContentError.internalError(underlying: NSError(
145+
domain: "\(Constants.baseErrorDomain).Internal",
146+
code: code.rawValue,
147+
userInfo: [NSLocalizedDescriptionKey: message]
148+
))
149+
AILog.error(code: code, message)
150+
return error
151+
}
152+
141153
/// Returns `true` if additional logging has been enabled via a launch argument.
142154
static func additionalLoggingEnabled() -> Bool {
143155
return ProcessInfo.processInfo.arguments.contains(enableArgumentKey)

FirebaseAI/Sources/Chat.swift

Lines changed: 9 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -19,35 +19,21 @@ import Foundation
1919
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
2020
public final class Chat: Sendable {
2121
private let model: GenerativeModel
22+
private let _history: History
2223

23-
/// Initializes a new chat representing a 1:1 conversation between model and user.
2424
init(model: GenerativeModel, history: [ModelContent]) {
2525
self.model = model
26-
self.history = history
26+
_history = History(history: history)
2727
}
2828

29-
private let historyLock = NSLock()
30-
private nonisolated(unsafe) var _history: [ModelContent] = []
3129
/// The previous content from the chat that has been successfully sent and received from the
3230
/// model. This will be provided to the model for each message sent as context for the discussion.
3331
public var history: [ModelContent] {
3432
get {
35-
historyLock.withLock { _history }
33+
return _history.history
3634
}
3735
set {
38-
historyLock.withLock { _history = newValue }
39-
}
40-
}
41-
42-
private func appendHistory(contentsOf: [ModelContent]) {
43-
historyLock.withLock {
44-
_history.append(contentsOf: contentsOf)
45-
}
46-
}
47-
48-
private func appendHistory(_ newElement: ModelContent) {
49-
historyLock.withLock {
50-
_history.append(newElement)
36+
_history.history = newValue
5137
}
5238
}
5339

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

8975
// Append the request and successful result to history, then return the value.
90-
appendHistory(contentsOf: newContent)
91-
appendHistory(toAdd)
76+
_history.append(contentsOf: newContent)
77+
_history.append(toAdd)
9278
return result
9379
}
9480

@@ -136,63 +122,16 @@ public final class Chat: Sendable {
136122
}
137123

138124
// Save the request.
139-
appendHistory(contentsOf: newContent)
125+
_history.append(contentsOf: newContent)
140126

141127
// Aggregate the content to add it to the history before we finish.
142-
let aggregated = self.aggregatedChunks(aggregatedContent)
143-
self.appendHistory(aggregated)
128+
let aggregated = self._history.aggregatedChunks(aggregatedContent)
129+
self._history.append(aggregated)
144130
continuation.finish()
145131
}
146132
}
147133
}
148134

149-
private func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent {
150-
var parts: [InternalPart] = []
151-
var combinedText = ""
152-
var combinedThoughts = ""
153-
154-
func flush() {
155-
if !combinedThoughts.isEmpty {
156-
parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil))
157-
combinedThoughts = ""
158-
}
159-
if !combinedText.isEmpty {
160-
parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil))
161-
combinedText = ""
162-
}
163-
}
164-
165-
// Loop through all the parts, aggregating the text.
166-
for part in chunks.flatMap({ $0.internalParts }) {
167-
// Only text parts may be combined.
168-
if case let .text(text) = part.data, part.thoughtSignature == nil {
169-
// Thought summaries must not be combined with regular text.
170-
if part.isThought ?? false {
171-
// If we were combining regular text, flush it before handling "thoughts".
172-
if !combinedText.isEmpty {
173-
flush()
174-
}
175-
combinedThoughts += text
176-
} else {
177-
// If we were combining "thoughts", flush it before handling regular text.
178-
if !combinedThoughts.isEmpty {
179-
flush()
180-
}
181-
combinedText += text
182-
}
183-
} else {
184-
// This is a non-combinable part (not text), flush any pending text.
185-
flush()
186-
parts.append(part)
187-
}
188-
}
189-
190-
// Flush any remaining text.
191-
flush()
192-
193-
return ModelContent(role: "model", parts: parts)
194-
}
195-
196135
/// Populates the `role` field with `user` if it doesn't exist. Required in chat sessions.
197136
private func populateContentRole(_ content: ModelContent) -> ModelContent {
198137
if content.role != nil {

FirebaseAI/Sources/FirebaseAI.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ public final class FirebaseAI: Sendable {
135135
)
136136
}
137137

138+
/// Initializes a new `TemplateGenerativeModel`.
139+
///
140+
/// - Returns: A new `TemplateGenerativeModel` instance.
141+
public func templateGenerativeModel() -> TemplateGenerativeModel {
142+
return TemplateGenerativeModel(
143+
generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo,
144+
urlSession: GenAIURLSession.default),
145+
apiConfig: apiConfig
146+
)
147+
}
148+
149+
/// Initializes a new `TemplateImagenModel`.
150+
///
151+
/// - Returns: A new `TemplateImagenModel` instance.
152+
public func templateImagenModel() -> TemplateImagenModel {
153+
return TemplateImagenModel(
154+
generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo,
155+
urlSession: GenAIURLSession.default),
156+
apiConfig: apiConfig
157+
)
158+
}
159+
138160
/// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters.
139161
///
140162
/// - Note: Refer to [the Firebase docs on the Live

FirebaseAI/Sources/GenerateContentRequest.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,23 @@ extension GenerateContentRequest {
7373
extension GenerateContentRequest: GenerativeAIRequest {
7474
typealias Response = GenerateContentResponse
7575

76-
var url: URL {
76+
func getURL() throws -> URL {
7777
let modelURL = "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model)"
78+
let urlString: String
7879
switch apiMethod {
7980
case .generateContent:
80-
return URL(string: "\(modelURL):\(apiMethod.rawValue)")!
81+
urlString = "\(modelURL):\(apiMethod.rawValue)"
8182
case .streamGenerateContent:
82-
return URL(string: "\(modelURL):\(apiMethod.rawValue)?alt=sse")!
83+
urlString = "\(modelURL):\(apiMethod.rawValue)?alt=sse"
8384
case .countTokens:
84-
fatalError("\(Self.self) should be a property of \(CountTokensRequest.self).")
85+
throw AILog.makeInternalError(
86+
message: "\(Self.self) should be a property of \(CountTokensRequest.self).",
87+
code: .malformedURL
88+
)
8589
}
90+
guard let url = URL(string: urlString) else {
91+
throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL)
92+
}
93+
return url
8694
}
8795
}

FirebaseAI/Sources/GenerativeAIRequest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import Foundation
1818
protocol GenerativeAIRequest: Sendable, Encodable {
1919
associatedtype Response: Sendable, Decodable
2020

21-
var url: URL { get }
21+
func getURL() throws -> URL
2222

2323
var options: RequestOptions { get }
2424
}

FirebaseAI/Sources/GenerativeAIService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ struct GenerativeAIService {
2626
/// The Firebase SDK version in the format `fire/<version>`.
2727
static let firebaseVersionTag = "fire/\(FirebaseVersion())"
2828

29-
private let firebaseInfo: FirebaseInfo
29+
let firebaseInfo: FirebaseInfo
3030

3131
private let urlSession: URLSession
3232

@@ -167,7 +167,7 @@ struct GenerativeAIService {
167167
// MARK: - Private Helpers
168168

169169
private func urlRequest<T: GenerativeAIRequest>(request: T) async throws -> URLRequest {
170-
var urlRequest = URLRequest(url: request.url)
170+
var urlRequest = try URLRequest(url: request.getURL())
171171
urlRequest.httpMethod = "POST"
172172
urlRequest.setValue(firebaseInfo.apiKey, forHTTPHeaderField: "x-goog-api-key")
173173
urlRequest.setValue(

FirebaseAI/Sources/History.swift

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
18+
final class History: Sendable {
19+
private let historyLock = NSLock()
20+
private nonisolated(unsafe) var _history: [ModelContent] = []
21+
/// The previous content from the chat that has been successfully sent and received from the
22+
/// model. This will be provided to the model for each message sent as context for the discussion.
23+
public var history: [ModelContent] {
24+
get {
25+
historyLock.withLock { _history }
26+
}
27+
set {
28+
historyLock.withLock { _history = newValue }
29+
}
30+
}
31+
32+
init(history: [ModelContent]) {
33+
self.history = history
34+
}
35+
36+
func append(contentsOf: [ModelContent]) {
37+
historyLock.withLock {
38+
_history.append(contentsOf: contentsOf)
39+
}
40+
}
41+
42+
func append(_ newElement: ModelContent) {
43+
historyLock.withLock {
44+
_history.append(newElement)
45+
}
46+
}
47+
48+
func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent {
49+
var parts: [InternalPart] = []
50+
var combinedText = ""
51+
var combinedThoughts = ""
52+
53+
func flush() {
54+
if !combinedThoughts.isEmpty {
55+
parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil))
56+
combinedThoughts = ""
57+
}
58+
if !combinedText.isEmpty {
59+
parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil))
60+
combinedText = ""
61+
}
62+
}
63+
64+
// Loop through all the parts, aggregating the text.
65+
for part in chunks.flatMap({ $0.internalParts }) {
66+
// Only text parts may be combined.
67+
if case let .text(text) = part.data, part.thoughtSignature == nil {
68+
// Thought summaries must not be combined with regular text.
69+
if part.isThought ?? false {
70+
// If we were combining regular text, flush it before handling "thoughts".
71+
if !combinedText.isEmpty {
72+
flush()
73+
}
74+
combinedThoughts += text
75+
} else {
76+
// If we were combining "thoughts", flush it before handling regular text.
77+
if !combinedThoughts.isEmpty {
78+
flush()
79+
}
80+
combinedText += text
81+
}
82+
} else {
83+
// This is a non-combinable part (not text), flush any pending text.
84+
flush()
85+
parts.append(part)
86+
}
87+
}
88+
89+
// Flush any remaining text.
90+
flush()
91+
92+
return ModelContent(role: "model", parts: parts)
93+
}
94+
}

0 commit comments

Comments
 (0)