@capgo/capacitor-llm 8.0.19 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapgoCapacitorLlm.podspec +1 -9
- package/Package.swift +21 -3
- package/README.md +137 -163
- package/android/build.gradle +3 -2
- package/android/src/main/java/ee/forgr/capgo_llm/LLM.java +605 -283
- package/android/src/main/java/ee/forgr/capgo_llm/LLMPlugin.java +36 -63
- package/dist/docs.json +66 -35
- package/dist/esm/definitions.d.ts +28 -20
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +68 -39
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +68 -39
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +68 -39
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LLMPlugin/LLMPlugin.swift +384 -613
- package/ios/Sources/LiteRTLM/Benchmark.swift +83 -0
- package/ios/Sources/LiteRTLM/Capabilities.swift +41 -0
- package/ios/Sources/LiteRTLM/Config.swift +172 -0
- package/ios/Sources/LiteRTLM/Conversation.swift +472 -0
- package/ios/Sources/LiteRTLM/Engine.swift +208 -0
- package/ios/Sources/LiteRTLM/ExperimentalFlags.swift +142 -0
- package/ios/Sources/LiteRTLM/LiteRTLMError.swift +156 -0
- package/ios/Sources/LiteRTLM/Message.swift +225 -0
- package/ios/Sources/LiteRTLM/Tool.swift +291 -0
- package/ios/Sources/LiteRTLM/ToolManager.swift +152 -0
- package/package.json +6 -5
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Copyright 2026 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
|
+
// https://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
|
+
import OSLog
|
|
17
|
+
import CLiteRTLM
|
|
18
|
+
|
|
19
|
+
typealias CConversationHandle = OpaquePointer
|
|
20
|
+
|
|
21
|
+
private let logger = Logger(
|
|
22
|
+
subsystem: "com.google.odml.litertlm.swift",
|
|
23
|
+
category: "Conversation"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
private let recurringToolCallLimit = 25
|
|
27
|
+
|
|
28
|
+
/// Represents a conversation with the LiteRT-LM model.
|
|
29
|
+
///
|
|
30
|
+
/// Example usage:
|
|
31
|
+
/// ```swift
|
|
32
|
+
/// // Assuming 'engine' is an instance of Engine
|
|
33
|
+
/// let conversation = try await engine.createConversation()
|
|
34
|
+
///
|
|
35
|
+
/// // Send a message and get the response.
|
|
36
|
+
/// let response = try conversation.sendMessage(Message("Hello world"))
|
|
37
|
+
///
|
|
38
|
+
/// // Send a message async with response chunks as AsyncThrowingStream.
|
|
39
|
+
/// for try await chunk in conversation.sendMessageStream(Message("Hello world")) {
|
|
40
|
+
/// print(chunk.text)
|
|
41
|
+
/// }
|
|
42
|
+
/// ```
|
|
43
|
+
///
|
|
44
|
+
/// This class facilitates interaction with the LiteRT-LM model by handling message sending
|
|
45
|
+
/// and response reception.
|
|
46
|
+
public class Conversation {
|
|
47
|
+
private let logger = Logger(
|
|
48
|
+
subsystem: "com.google.ai.edge.litertlm.swift",
|
|
49
|
+
category: "Conversation"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
private var handle: CConversationHandle?
|
|
53
|
+
private let toolManager: ToolManager
|
|
54
|
+
|
|
55
|
+
/// Whether the conversation is alive and ready to be used.
|
|
56
|
+
public var isAlive: Bool {
|
|
57
|
+
return handle != nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
init(handle: CConversationHandle, toolManager: ToolManager) {
|
|
61
|
+
self.handle = handle
|
|
62
|
+
self.toolManager = toolManager
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
deinit {
|
|
66
|
+
if let handle = handle {
|
|
67
|
+
litert_lm_conversation_delete(handle)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Sends a message to the model and returns the response. This is a synchronous call.
|
|
72
|
+
///
|
|
73
|
+
/// - Parameter message: The message to send to the model.
|
|
74
|
+
/// - Parameter extraContext: The extra context to send to the model.
|
|
75
|
+
/// - Returns: The model's response message.
|
|
76
|
+
/// - Throws: A `LiteRTLMError` if sending the message fails or the model
|
|
77
|
+
/// returns an invalid response.
|
|
78
|
+
public func sendMessage(_ message: Message, extraContext: [String: Any]? = nil) async throws
|
|
79
|
+
-> Message
|
|
80
|
+
{
|
|
81
|
+
let handle = try checkIsAlive()
|
|
82
|
+
|
|
83
|
+
var currentMessageJson: [String: Any] = message.toJson
|
|
84
|
+
|
|
85
|
+
for _ in 0..<recurringToolCallLimit {
|
|
86
|
+
let (responseJson, responseString) = try attemptSendMessage(
|
|
87
|
+
handle: handle, messageJson: currentMessageJson, extraContext: extraContext)
|
|
88
|
+
|
|
89
|
+
guard let toolCalls = responseJson["tool_calls"] as? [[String: Any]] else {
|
|
90
|
+
if responseJson["content"] != nil || responseJson["channels"] != nil {
|
|
91
|
+
return try Conversation.jsonToMessage(responseString)
|
|
92
|
+
} else {
|
|
93
|
+
throw LiteRTLMError.conversation(.invalidResponse(responseString))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
currentMessageJson = try await handleToolCalls(toolCalls)
|
|
97
|
+
}
|
|
98
|
+
throw LiteRTLMError.conversation(.recurringToolCallLimitExceeded(limit: recurringToolCallLimit))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func attemptSendMessage(
|
|
102
|
+
handle: CConversationHandle, messageJson: [String: Any], extraContext: [String: Any]?
|
|
103
|
+
) throws
|
|
104
|
+
-> (responseJson: [String: Any], responseString: String)
|
|
105
|
+
{
|
|
106
|
+
let messageData = try JSONSerialization.data(withJSONObject: messageJson)
|
|
107
|
+
guard let messageString = String(data: messageData, encoding: .utf8) else {
|
|
108
|
+
throw LiteRTLMError.conversation(.failedToSerializeMessage)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
var extraContextString: String? = nil
|
|
112
|
+
if let extraContext = extraContext, !extraContext.isEmpty,
|
|
113
|
+
let extraData = try? JSONSerialization.data(withJSONObject: extraContext)
|
|
114
|
+
{
|
|
115
|
+
extraContextString = String(data: extraData, encoding: .utf8)
|
|
116
|
+
}
|
|
117
|
+
let optionalArgs = litert_lm_conversation_optional_args_create()
|
|
118
|
+
if let visualTokenBudget = ExperimentalFlags.visualTokenBudget {
|
|
119
|
+
litert_lm_conversation_optional_args_set_visual_token_budget(optionalArgs, Int32(visualTokenBudget))
|
|
120
|
+
}
|
|
121
|
+
defer { litert_lm_conversation_optional_args_delete(optionalArgs) }
|
|
122
|
+
|
|
123
|
+
guard let responsePtr = litert_lm_conversation_send_message(
|
|
124
|
+
handle, messageString, extraContextString, optionalArgs)
|
|
125
|
+
else {
|
|
126
|
+
throw LiteRTLMError.conversation(.invalidResponse("Native sendMessage returned null."))
|
|
127
|
+
|
|
128
|
+
}
|
|
129
|
+
// Delete the response pointer at the end of each iteration. Handled by defer block.
|
|
130
|
+
let responsePtrRef = responsePtr
|
|
131
|
+
defer { litert_lm_json_response_delete(responsePtrRef) }
|
|
132
|
+
|
|
133
|
+
guard let responseChars = litert_lm_json_response_get_string(responsePtr) else {
|
|
134
|
+
throw LiteRTLMError.conversation(
|
|
135
|
+
.invalidResponse("Native get string for response returned null."))
|
|
136
|
+
}
|
|
137
|
+
let responseString = String(cString: responseChars)
|
|
138
|
+
|
|
139
|
+
guard let responseData = responseString.data(using: .utf8),
|
|
140
|
+
let responseJson = try JSONSerialization.jsonObject(with: responseData) as? [String: Any]
|
|
141
|
+
else {
|
|
142
|
+
throw LiteRTLMError.conversation(.invalidJson("Failed to parse native response JSON."))
|
|
143
|
+
}
|
|
144
|
+
return (responseJson, responseString)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fileprivate func handleToolCalls(_ toolCalls: [[String: Any]]) async throws -> [String: Any] {
|
|
148
|
+
var toolResponses: [[String: Any]] = []
|
|
149
|
+
|
|
150
|
+
for toolCall in toolCalls {
|
|
151
|
+
guard let function = toolCall["function"] as? [String: Any],
|
|
152
|
+
let name = function["name"] as? String,
|
|
153
|
+
let argsObject = function["arguments"] as? [String: Any]
|
|
154
|
+
else {
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
do {
|
|
158
|
+
let result = try await toolManager.execute(name: name, arguments: argsObject)
|
|
159
|
+
|
|
160
|
+
toolResponses.append([
|
|
161
|
+
"type": "tool_response",
|
|
162
|
+
"name": name,
|
|
163
|
+
"response": result,
|
|
164
|
+
])
|
|
165
|
+
} catch {
|
|
166
|
+
throw LiteRTLMError.conversation(.toolExecutionError(name: name, error: "\(error)"))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return ["role": "tool", "content": toolResponses]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Throws an error if the conversation is not alive.
|
|
174
|
+
///
|
|
175
|
+
/// - Returns: The `OpaquePointer` handle if the conversation is alive.
|
|
176
|
+
/// - Throws: A `LiteRTLMError` if `handle` is nil, indicating the conversation is not alive.
|
|
177
|
+
fileprivate func checkIsAlive() throws -> OpaquePointer {
|
|
178
|
+
guard let handle else {
|
|
179
|
+
throw LiteRTLMError.conversation(.notAlive)
|
|
180
|
+
}
|
|
181
|
+
return handle
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Sends a message to the model and returns an async stream of response chunks.
|
|
185
|
+
///
|
|
186
|
+
/// - Parameter message: The message to send.
|
|
187
|
+
/// - Parameter extraContext: The extra context to send to the model.
|
|
188
|
+
/// - Returns: An async throwing stream of `Message` chunks.
|
|
189
|
+
public func sendMessageStream(_ message: Message, extraContext: [String: Any]? = nil)
|
|
190
|
+
-> AsyncThrowingStream<Message, Error>
|
|
191
|
+
{
|
|
192
|
+
return AsyncThrowingStream { continuation in
|
|
193
|
+
do {
|
|
194
|
+
let handle = try self.checkIsAlive()
|
|
195
|
+
let messageJson: [String: Any] = message.toJson
|
|
196
|
+
let context = StreamContext(continuation: continuation, conversation: self)
|
|
197
|
+
|
|
198
|
+
try self.sendToStream(
|
|
199
|
+
handle: handle, messageJson: messageJson, extraContext: extraContext, context: context)
|
|
200
|
+
} catch {
|
|
201
|
+
continuation.finish(throwing: error)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// Sends a message to the model and handles the response via a streaming callback.
|
|
207
|
+
///
|
|
208
|
+
/// This function is used internally by `sendMessageStream` and for handling
|
|
209
|
+
/// subsequent tool call responses within the stream.
|
|
210
|
+
///
|
|
211
|
+
/// - Parameters:
|
|
212
|
+
/// - handle: The `CConversationHandle` for the current conversation.
|
|
213
|
+
/// - messageJson: The message to send, represented as a JSON dictionary.
|
|
214
|
+
/// - extraContext: The extra context to send to the model.
|
|
215
|
+
/// - context: The `StreamContext` containing the `AsyncThrowingStream.Continuation`
|
|
216
|
+
/// and other state for the streaming process.
|
|
217
|
+
/// - Throws: A `LiteRTLMError` if the message fails to send or the response is invalid.
|
|
218
|
+
/// native `send_message_stream` call fails.
|
|
219
|
+
func sendToStream(
|
|
220
|
+
handle: CConversationHandle,
|
|
221
|
+
messageJson: [String: Any],
|
|
222
|
+
extraContext: [String: Any]? = nil,
|
|
223
|
+
context: StreamContext
|
|
224
|
+
) throws {
|
|
225
|
+
let messageData = try JSONSerialization.data(withJSONObject: messageJson)
|
|
226
|
+
guard let messageString = String(data: messageData, encoding: .utf8) else {
|
|
227
|
+
throw LiteRTLMError.conversation(.failedToSerializeMessage)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
var extraContextString: String? = nil
|
|
231
|
+
if let extraContext = extraContext, !extraContext.isEmpty,
|
|
232
|
+
let extraData = try? JSONSerialization.data(withJSONObject: extraContext)
|
|
233
|
+
{
|
|
234
|
+
extraContextString = String(data: extraData, encoding: .utf8)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let optionalArgs = litert_lm_conversation_optional_args_create()
|
|
238
|
+
if let visualTokenBudget = ExperimentalFlags.visualTokenBudget {
|
|
239
|
+
litert_lm_conversation_optional_args_set_visual_token_budget(optionalArgs, Int32(visualTokenBudget))
|
|
240
|
+
}
|
|
241
|
+
defer { litert_lm_conversation_optional_args_delete(optionalArgs) }
|
|
242
|
+
|
|
243
|
+
let contextPtr = Unmanaged.passRetained(context).toOpaque()
|
|
244
|
+
|
|
245
|
+
let status = litert_lm_conversation_send_message_stream(
|
|
246
|
+
handle,
|
|
247
|
+
messageString,
|
|
248
|
+
extraContextString,
|
|
249
|
+
optionalArgs,
|
|
250
|
+
streamCallback,
|
|
251
|
+
contextPtr
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
guard status == 0 else {
|
|
255
|
+
Unmanaged<StreamContext>.fromOpaque(contextPtr).release()
|
|
256
|
+
throw LiteRTLMError.conversation(.failedToStartStream(status: Int(status)))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Cancels the ongoing asynchronous inference process.
|
|
261
|
+
public func cancel() throws {
|
|
262
|
+
let handle = try checkIsAlive()
|
|
263
|
+
litert_lm_conversation_cancel_process(handle)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Renders the message into a string for testing and logging.
|
|
267
|
+
///
|
|
268
|
+
/// This function does not need to be called for actual message sending, as the `sendMessage` and
|
|
269
|
+
/// `sendMessageStream` functions will handle rendering internally.
|
|
270
|
+
///
|
|
271
|
+
/// - Parameter message: The message to render.
|
|
272
|
+
/// - Returns: The rendered message string.
|
|
273
|
+
/// - Throws: A `LiteRTLMError` if the conversation is not alive, serializing fails, or rendering fails.
|
|
274
|
+
public func renderMessageIntoString(_ message: Message) throws -> String {
|
|
275
|
+
let handle = try checkIsAlive()
|
|
276
|
+
let messageData = try JSONSerialization.data(withJSONObject: message.toJson)
|
|
277
|
+
guard let messageString = String(data: messageData, encoding: .utf8) else {
|
|
278
|
+
throw LiteRTLMError.conversation(.failedToSerializeMessage)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
guard let cString = litert_lm_conversation_render_message_to_string(handle, messageString)
|
|
282
|
+
else {
|
|
283
|
+
throw LiteRTLMError.conversation(.invalidResponse("Failed to render message into string."))
|
|
284
|
+
}
|
|
285
|
+
return String(cString: cString)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Retrieves the benchmark information from the conversation.
|
|
289
|
+
///
|
|
290
|
+
/// - Returns: The benchmark information
|
|
291
|
+
/// - Throws: A `LiteRTLMError` if the benchmark flag is not enabled or info is unavailable.
|
|
292
|
+
public func getBenchmarkInfo() throws -> BenchmarkInfo {
|
|
293
|
+
let handle = try checkIsAlive()
|
|
294
|
+
|
|
295
|
+
if !ExperimentalFlags.enableBenchmark {
|
|
296
|
+
throw LiteRTLMError.conversation(.benchmarkNotEnabled)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
guard let benchmarkInfoPtr = litert_lm_conversation_get_benchmark_info(handle) else {
|
|
300
|
+
throw LiteRTLMError.conversation(.benchmarkInfoUnavailable)
|
|
301
|
+
}
|
|
302
|
+
defer { litert_lm_benchmark_info_delete(benchmarkInfoPtr) }
|
|
303
|
+
|
|
304
|
+
let numPrefillTurns = litert_lm_benchmark_info_get_num_prefill_turns(benchmarkInfoPtr)
|
|
305
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchmarkInfoPtr)
|
|
306
|
+
|
|
307
|
+
let initTimeInSecond = litert_lm_benchmark_info_get_total_init_time_in_second(benchmarkInfoPtr)
|
|
308
|
+
let timeToFirstTokenInSecond = litert_lm_benchmark_info_get_time_to_first_token(
|
|
309
|
+
benchmarkInfoPtr)
|
|
310
|
+
|
|
311
|
+
let lastPrefillTokenCount: Int =
|
|
312
|
+
numPrefillTurns > 0
|
|
313
|
+
? Int(
|
|
314
|
+
litert_lm_benchmark_info_get_prefill_token_count_at(
|
|
315
|
+
benchmarkInfoPtr, numPrefillTurns - 1)) : 0
|
|
316
|
+
let lastPrefillTokensPerSec: Double =
|
|
317
|
+
numPrefillTurns > 0
|
|
318
|
+
? litert_lm_benchmark_info_get_prefill_tokens_per_sec_at(
|
|
319
|
+
benchmarkInfoPtr, numPrefillTurns - 1) : 0.0
|
|
320
|
+
|
|
321
|
+
let lastDecodeTokenCount: Int =
|
|
322
|
+
numDecodeTurns > 0
|
|
323
|
+
? Int(
|
|
324
|
+
litert_lm_benchmark_info_get_decode_token_count_at(
|
|
325
|
+
benchmarkInfoPtr, numDecodeTurns - 1)) : 0
|
|
326
|
+
let lastDecodeTokensPerSec: Double =
|
|
327
|
+
numDecodeTurns > 0
|
|
328
|
+
? litert_lm_benchmark_info_get_decode_tokens_per_sec_at(
|
|
329
|
+
benchmarkInfoPtr, numDecodeTurns - 1) : 0.0
|
|
330
|
+
|
|
331
|
+
return BenchmarkInfo(
|
|
332
|
+
initTimeInSecond: initTimeInSecond,
|
|
333
|
+
timeToFirstTokenInSecond: timeToFirstTokenInSecond,
|
|
334
|
+
lastPrefillTokenCount: lastPrefillTokenCount,
|
|
335
|
+
lastDecodeTokenCount: lastDecodeTokenCount,
|
|
336
|
+
lastPrefillTokensPerSecond: lastPrefillTokensPerSec,
|
|
337
|
+
lastDecodeTokensPerSecond: lastDecodeTokensPerSec
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// Internal Helper Function to convert a JSON string to a `Message`.
|
|
342
|
+
///
|
|
343
|
+
/// - Parameter jsonString: The JSON string to convert.
|
|
344
|
+
/// - Returns: The `Message` representation of the JSON string.
|
|
345
|
+
/// - Throws: `LiteRTLMError` if the JSON string is invalid.
|
|
346
|
+
public static func jsonToMessage(_ jsonString: String) throws -> Message {
|
|
347
|
+
guard let data = jsonString.data(using: .utf8),
|
|
348
|
+
let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
349
|
+
else {
|
|
350
|
+
throw LiteRTLMError.message(.failedToConvertToJson)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
var contents: [Content] = []
|
|
354
|
+
if let contentArray = jsonObject["content"] as? [[String: Any]] {
|
|
355
|
+
for item in contentArray {
|
|
356
|
+
if let type = item["type"] as? String, type == "text", let text = item["text"] as? String {
|
|
357
|
+
contents.append(.text(text))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
var channels: [String: String] = [:]
|
|
363
|
+
if let channelsDict = jsonObject["channels"] as? [String: Any] {
|
|
364
|
+
for (key, value) in channelsDict {
|
|
365
|
+
if let strValue = value as? String {
|
|
366
|
+
channels[key] = strValue
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if contents.isEmpty && channels.isEmpty {
|
|
372
|
+
throw LiteRTLMError.message(.invalidContent)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return Message(contents: contents, channels: channels)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// Context object to bridge the C callback to the Swift AsyncThrowingStream.
|
|
379
|
+
class StreamContext {
|
|
380
|
+
let continuation: AsyncThrowingStream<Message, Error>.Continuation
|
|
381
|
+
let conversation: Conversation
|
|
382
|
+
var toolCallCount: Int = 0
|
|
383
|
+
var pendingToolCalls: [[String: Any]] = []
|
|
384
|
+
|
|
385
|
+
init(continuation: AsyncThrowingStream<Message, Error>.Continuation, conversation: Conversation)
|
|
386
|
+
{
|
|
387
|
+
self.continuation = continuation
|
|
388
|
+
self.conversation = conversation
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// A callback function to bridge the C callback to the Swift AsyncThrowingStream.
|
|
394
|
+
private func streamCallback(
|
|
395
|
+
userData: UnsafeMutableRawPointer?,
|
|
396
|
+
responseJson: UnsafePointer<CChar>?,
|
|
397
|
+
isFinal: Bool,
|
|
398
|
+
errorMessage: UnsafePointer<CChar>?
|
|
399
|
+
) {
|
|
400
|
+
guard let userData = userData else { return }
|
|
401
|
+
|
|
402
|
+
let context = Unmanaged<Conversation.StreamContext>.fromOpaque(userData).takeUnretainedValue()
|
|
403
|
+
|
|
404
|
+
if let errorMessage = errorMessage {
|
|
405
|
+
let errorString = String(cString: errorMessage)
|
|
406
|
+
let error = LiteRTLMError.conversation(.invalidResponse(errorString))
|
|
407
|
+
context.continuation.finish(throwing: error)
|
|
408
|
+
|
|
409
|
+
Unmanaged<Conversation.StreamContext>.fromOpaque(userData).release()
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if let responseJson = responseJson {
|
|
414
|
+
let responseString = String(cString: responseJson)
|
|
415
|
+
do {
|
|
416
|
+
guard let responseData = responseString.data(using: .utf8),
|
|
417
|
+
let jsonObject = try JSONSerialization.jsonObject(with: responseData) as? [String: Any]
|
|
418
|
+
else {
|
|
419
|
+
throw LiteRTLMError.conversation(.invalidJson("Invalid JSON chunk"))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if let toolCalls = jsonObject["tool_calls"] as? [[String: Any]] {
|
|
423
|
+
context.pendingToolCalls.append(contentsOf: toolCalls)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if jsonObject["content"] != nil || jsonObject["channels"] != nil {
|
|
427
|
+
let message = try Conversation.jsonToMessage(responseString)
|
|
428
|
+
context.continuation.yield(message)
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
logger.error("Failed to parse response JSON: \(error.localizedDescription)")
|
|
432
|
+
context.continuation.finish(throwing: error)
|
|
433
|
+
Unmanaged<Conversation.StreamContext>.fromOpaque(userData).release()
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if isFinal {
|
|
439
|
+
if !context.pendingToolCalls.isEmpty {
|
|
440
|
+
if context.toolCallCount >= recurringToolCallLimit {
|
|
441
|
+
context.continuation.finish(
|
|
442
|
+
throwing: LiteRTLMError.conversation(
|
|
443
|
+
.recurringToolCallLimitExceeded(limit: recurringToolCallLimit)))
|
|
444
|
+
Unmanaged<Conversation.StreamContext>.fromOpaque(userData).release()
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
context.toolCallCount += 1
|
|
449
|
+
let toolCalls = context.pendingToolCalls
|
|
450
|
+
context.pendingToolCalls = []
|
|
451
|
+
|
|
452
|
+
Task {
|
|
453
|
+
do {
|
|
454
|
+
let toolResponseJson = try await context.conversation.handleToolCalls(toolCalls)
|
|
455
|
+
try context.conversation.sendToStream(
|
|
456
|
+
handle: context.conversation.checkIsAlive(),
|
|
457
|
+
messageJson: toolResponseJson,
|
|
458
|
+
context: context
|
|
459
|
+
)
|
|
460
|
+
} catch {
|
|
461
|
+
context.continuation.finish(throwing: error)
|
|
462
|
+
}
|
|
463
|
+
// Release the reference for the current (finished) call.
|
|
464
|
+
// The new call from sendToStream created its own retained reference.
|
|
465
|
+
Unmanaged<Conversation.StreamContext>.fromOpaque(userData).release()
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
context.continuation.finish()
|
|
469
|
+
Unmanaged<Conversation.StreamContext>.fromOpaque(userData).release()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Copyright 2026 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
|
+
// https://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
|
+
import OSLog
|
|
17
|
+
import CLiteRTLM
|
|
18
|
+
|
|
19
|
+
/// Manages the lifecycle of a LiteRT-LM engine, providing an interface for interacting with the
|
|
20
|
+
/// underlying native library.
|
|
21
|
+
///
|
|
22
|
+
/// Example usage:
|
|
23
|
+
/// ```
|
|
24
|
+
/// let config = try EngineConfig(modelPath: "...")
|
|
25
|
+
/// let engine = Engine(engineConfig: config)
|
|
26
|
+
/// try await engine.initialize()
|
|
27
|
+
/// ```
|
|
28
|
+
public actor Engine {
|
|
29
|
+
private let logger = Logger(
|
|
30
|
+
subsystem: "com.google.odml.litertlm.swift",
|
|
31
|
+
category: "Engine"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
/// The configuration for the engine.
|
|
35
|
+
public let engineConfig: EngineConfig
|
|
36
|
+
|
|
37
|
+
/// The native handle to the LiteRT-LM engine. A non-nil value indicates an initialized engine.
|
|
38
|
+
private var handle: OpaquePointer? = nil
|
|
39
|
+
|
|
40
|
+
/// - Parameter engineConfig: The configuration for the engine.
|
|
41
|
+
public init(engineConfig: EngineConfig) {
|
|
42
|
+
self.engineConfig = engineConfig
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Returns `true` if the engine is initialized and ready for use; `false` otherwise.
|
|
46
|
+
public func isInitialized() -> Bool {
|
|
47
|
+
return handle != nil
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Initializes the native LiteRT-LM engine.
|
|
51
|
+
///
|
|
52
|
+
/// **Note:** This operation can take a significant amount of time (e.g., 10 seconds) depending on
|
|
53
|
+
/// the model size and device hardware. It is strongly recommended to call this method on a
|
|
54
|
+
/// background thread to avoid blocking the main thread.
|
|
55
|
+
///
|
|
56
|
+
/// - Throws: A `LiteRTLMError` if the engine fails to initialize.
|
|
57
|
+
public func initialize() throws {
|
|
58
|
+
try initializeInternal(benchmarkPrefillTokens: nil, benchmarkDecodeTokens: nil)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Initializes the native LiteRT-LM engine specifically for benchmarking, avoiding global state params.
|
|
62
|
+
func initializeForBenchmark(prefillTokens: Int, decodeTokens: Int) throws {
|
|
63
|
+
try initializeInternal(
|
|
64
|
+
benchmarkPrefillTokens: prefillTokens, benchmarkDecodeTokens: decodeTokens)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private func initializeInternal(benchmarkPrefillTokens: Int?, benchmarkDecodeTokens: Int?) throws
|
|
68
|
+
{
|
|
69
|
+
if isInitialized() {
|
|
70
|
+
throw LiteRTLMError.engine(.alreadyInitialized)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Convert the enums to strings for passing to the native library.
|
|
74
|
+
let backendStr = engineConfig.backend.rawValue
|
|
75
|
+
let visionBackendStr = engineConfig.visionBackend?.rawValue
|
|
76
|
+
let audioBackendStr = engineConfig.audioBackend?.rawValue
|
|
77
|
+
|
|
78
|
+
let settings = litert_lm_engine_settings_create(
|
|
79
|
+
engineConfig.modelPath, backendStr, visionBackendStr, audioBackendStr)
|
|
80
|
+
|
|
81
|
+
guard let settings else {
|
|
82
|
+
throw LiteRTLMError.engine(.failedToCreateSettings)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
defer { litert_lm_engine_settings_delete(settings) }
|
|
86
|
+
|
|
87
|
+
if let maxNumTokens = engineConfig.maxNumTokens {
|
|
88
|
+
litert_lm_engine_settings_set_max_num_tokens(settings, Int32(maxNumTokens))
|
|
89
|
+
}
|
|
90
|
+
if let cacheDir = engineConfig.cacheDir {
|
|
91
|
+
litert_lm_engine_settings_set_cache_dir(settings, cacheDir)
|
|
92
|
+
}
|
|
93
|
+
if let prefill = benchmarkPrefillTokens, let decode = benchmarkDecodeTokens {
|
|
94
|
+
litert_lm_engine_settings_enable_benchmark(settings)
|
|
95
|
+
litert_lm_engine_settings_set_num_prefill_tokens(settings, Int32(prefill))
|
|
96
|
+
litert_lm_engine_settings_set_num_decode_tokens(settings, Int32(decode))
|
|
97
|
+
} else if ExperimentalFlags.enableBenchmark {
|
|
98
|
+
litert_lm_engine_settings_enable_benchmark(settings)
|
|
99
|
+
}
|
|
100
|
+
if let enableSpeculativeDecoding = ExperimentalFlags.enableSpeculativeDecoding {
|
|
101
|
+
litert_lm_engine_settings_set_enable_speculative_decoding(settings, enableSpeculativeDecoding)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
guard let engine = litert_lm_engine_create(settings) else {
|
|
105
|
+
throw LiteRTLMError.engine(.failedToCreateEngine)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
self.handle = engine
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Creates a new `Conversation` from the initialized engine.
|
|
112
|
+
///
|
|
113
|
+
/// - Parameter ConversationConfig: The configuration for the conversation.
|
|
114
|
+
/// - Returns: `Conversation` The created conversation.
|
|
115
|
+
/// - Throws: A `LiteRTLMError` if the conversation creation fails.
|
|
116
|
+
///
|
|
117
|
+
public func createConversation(with config: ConversationConfig? = nil) throws -> Conversation {
|
|
118
|
+
guard isInitialized() else {
|
|
119
|
+
throw LiteRTLMError.engine(.notInitialized)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// We can force unwrap handle here because the engine is guaranteed to be initialized, and
|
|
123
|
+
// initialization will set the handle.
|
|
124
|
+
let engineHandle = self.handle!
|
|
125
|
+
|
|
126
|
+
let conversationConfig = config ?? ConversationConfig()
|
|
127
|
+
|
|
128
|
+
let systemMessage = conversationConfig.systemMessage
|
|
129
|
+
let initialSystemMessageCount = conversationConfig.initialMessages.filter { $0.role == .system }
|
|
130
|
+
.count
|
|
131
|
+
|
|
132
|
+
if systemMessage != nil && initialSystemMessageCount > 0 {
|
|
133
|
+
throw LiteRTLMError.config(.multipleSystemMessages)
|
|
134
|
+
}
|
|
135
|
+
if initialSystemMessageCount > 1 {
|
|
136
|
+
throw LiteRTLMError.config(.multipleSystemMessages)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let toolManager = ToolManager(tools: conversationConfig.tools)
|
|
140
|
+
|
|
141
|
+
let systemMessageJsonStr = (try? conversationConfig.systemMessage?.contents.jsonString) ?? ""
|
|
142
|
+
let toolDescriptionJsonStr = toolManager.toolsJsonDescription
|
|
143
|
+
|
|
144
|
+
let initialMessagesJson = conversationConfig.initialMessages.map { $0.toJson }
|
|
145
|
+
let messagesJsonStr: String
|
|
146
|
+
if !initialMessagesJson.isEmpty,
|
|
147
|
+
let messagesData = try? JSONSerialization.data(
|
|
148
|
+
withJSONObject: initialMessagesJson, options: []),
|
|
149
|
+
let serializedStr = String(data: messagesData, encoding: .utf8)
|
|
150
|
+
{
|
|
151
|
+
messagesJsonStr = serializedStr
|
|
152
|
+
} else {
|
|
153
|
+
messagesJsonStr = ""
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let cSessionConfig = litert_lm_session_config_create()
|
|
157
|
+
guard let cSessionConfig else {
|
|
158
|
+
throw LiteRTLMError.engine(.failedToCreateSessionConfig)
|
|
159
|
+
}
|
|
160
|
+
defer { litert_lm_session_config_delete(cSessionConfig) }
|
|
161
|
+
|
|
162
|
+
if let samplerParams = conversationConfig.samplerConfig {
|
|
163
|
+
var params = LiteRtLmSamplerParams(
|
|
164
|
+
// Based on the current engine implementation, when SamplerConfig is set, we must switch to
|
|
165
|
+
// the topP sampling type.
|
|
166
|
+
type: kLiteRtLmSamplerTypeTopP,
|
|
167
|
+
top_k: Int32(samplerParams.topK),
|
|
168
|
+
top_p: samplerParams.topP,
|
|
169
|
+
temperature: samplerParams.temperature,
|
|
170
|
+
seed: Int32(samplerParams.seed)
|
|
171
|
+
)
|
|
172
|
+
litert_lm_session_config_set_sampler_params(cSessionConfig, ¶ms)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
guard let cConversationConfig = litert_lm_conversation_config_create() else {
|
|
176
|
+
throw LiteRTLMError.engine(.failedToCreateConversationConfig)
|
|
177
|
+
}
|
|
178
|
+
defer { litert_lm_conversation_config_delete(cConversationConfig) }
|
|
179
|
+
|
|
180
|
+
litert_lm_conversation_config_set_session_config(cConversationConfig, cSessionConfig)
|
|
181
|
+
if !systemMessageJsonStr.isEmpty {
|
|
182
|
+
litert_lm_conversation_config_set_system_message(cConversationConfig, systemMessageJsonStr)
|
|
183
|
+
}
|
|
184
|
+
if !toolDescriptionJsonStr.isEmpty {
|
|
185
|
+
litert_lm_conversation_config_set_tools(cConversationConfig, toolDescriptionJsonStr)
|
|
186
|
+
}
|
|
187
|
+
if !messagesJsonStr.isEmpty {
|
|
188
|
+
litert_lm_conversation_config_set_messages(cConversationConfig, messagesJsonStr)
|
|
189
|
+
}
|
|
190
|
+
litert_lm_conversation_config_set_enable_constrained_decoding(
|
|
191
|
+
cConversationConfig, ExperimentalFlags.enableConversationConstrainedDecoding)
|
|
192
|
+
|
|
193
|
+
guard
|
|
194
|
+
let conversationHandle = litert_lm_conversation_create(
|
|
195
|
+
engineHandle, cConversationConfig)
|
|
196
|
+
else {
|
|
197
|
+
throw LiteRTLMError.engine(.failedToCreateConversation)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Conversation(handle: conversationHandle, toolManager: toolManager)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
deinit {
|
|
204
|
+
if let handle = handle {
|
|
205
|
+
litert_lm_engine_delete(handle)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|