@swiftbrowser/native 0.18.2 → 0.19.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.
Files changed (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,409 @@
1
+ import Foundation
2
+ import SwiftBrowserNative
3
+
4
+ /// Open language models run on the device with llama.cpp, for the app's
5
+ /// `LanguageModelSession`s where Apple Intelligence cannot answer (or where
6
+ /// the viewer chooses one). Install one, or several to choose from, at launch:
7
+ ///
8
+ /// ```swift
9
+ /// LocalLanguageModel.install([.llama3_2_1B, .qwen2_5_1_5B, .llama3_2_3B])
10
+ /// ```
11
+ ///
12
+ /// The first is the default; the viewer's choice (`selectModel` in
13
+ /// @swiftbrowser/react) is remembered across launches.
14
+ ///
15
+ /// The model is a GGUF file the app fetches when the viewer asks for it
16
+ /// (`SystemLanguageModel.default.download()` in @swiftbrowser/react, after
17
+ /// showing its size; until it is here availability is `modelNotReady`, with
18
+ /// the download's state), kept in Application Support and excluded from
19
+ /// backups, loaded on the first prompt or `prewarm()`, and removed with
20
+ /// `deleteDownload()`.
21
+ public enum LocalLanguageModel {
22
+ /// A GGUF model to fetch and run.
23
+ public struct Model: Sendable, Equatable {
24
+ /// How the app names it to the viewer.
25
+ public var name: String
26
+ /// A line about it: what it is good at.
27
+ public var summary: String?
28
+ /// The file's name on the device, which is also the model's id.
29
+ public var fileName: String
30
+ public var url: URL
31
+ /// The file's size, checked once it has downloaded; also the download's
32
+ /// progress where the server does not say how big the file is.
33
+ public var bytes: Int64?
34
+ /// How many tokens of the conversation the model keeps in mind.
35
+ public var contextLength: Int
36
+ /// Layers run on the GPU with Metal (all of them by default); 0 runs on the CPU.
37
+ public var gpuLayers: Int32
38
+
39
+ public init(name: String, summary: String? = nil, fileName: String, url: URL, bytes: Int64? = nil, contextLength: Int = 4096, gpuLayers: Int32 = 999) {
40
+ self.name = name
41
+ self.summary = summary
42
+ self.fileName = fileName
43
+ self.url = url
44
+ self.bytes = bytes
45
+ self.contextLength = contextLength
46
+ self.gpuLayers = gpuLayers
47
+ }
48
+
49
+ /// Meta's Llama 3.2 1B Instruct, 4-bit (Q4_K_M): 808 MB, at home on a
50
+ /// phone with 6 GB of memory. Built with Llama; Meta's Llama 3.2
51
+ /// Community License applies.
52
+ public static let llama3_2_1B = Model(
53
+ name: "Llama 3.2 1B",
54
+ summary: "Fastest",
55
+ fileName: "Llama-3.2-1B-Instruct-Q4_K_M.gguf",
56
+ url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf")!,
57
+ bytes: 807_694_464
58
+ )
59
+
60
+ /// Alibaba's Qwen 2.5 1.5B Instruct, 4-bit (Q4_K_M): 1.1 GB, the
61
+ /// steadiest of these with tools. Apache 2.0.
62
+ public static let qwen2_5_1_5B = Model(
63
+ name: "Qwen 2.5 1.5B",
64
+ summary: "Best with tools",
65
+ fileName: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
66
+ url: URL(string: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf")!,
67
+ bytes: 1_117_320_736
68
+ )
69
+
70
+ /// Meta's Llama 3.2 3B Instruct, 4-bit (Q4_K_M): 2.0 GB, the most
71
+ /// capable here, and the most memory (a phone with 6 GB runs it close
72
+ /// to its limit). Built with Llama; Meta's Llama 3.2 Community License applies.
73
+ public static let llama3_2_3B = Model(
74
+ name: "Llama 3.2 3B",
75
+ summary: "Most capable",
76
+ fileName: "Llama-3.2-3B-Instruct-Q4_K_M.gguf",
77
+ url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf")!,
78
+ bytes: 2_019_377_696
79
+ )
80
+ }
81
+
82
+ /// Makes `models` the app's `LanguageModels.fallback`, the first the
83
+ /// default. `directory` is where the files are kept (Application
84
+ /// Support's SwiftBrowserLocalModel folder by default);
85
+ /// `allowsCellularAccess` false waits for Wi-Fi to fetch them.
86
+ @MainActor
87
+ public static func install(_ models: [Model], directory: URL? = nil, allowsCellularAccess: Bool = true) {
88
+ LanguageModels.fallback = provider(models, directory: directory, allowsCellularAccess: allowsCellularAccess)
89
+ }
90
+
91
+ @MainActor
92
+ public static func install(_ model: Model, directory: URL? = nil, allowsCellularAccess: Bool = true) {
93
+ install([model], directory: directory, allowsCellularAccess: allowsCellularAccess)
94
+ }
95
+
96
+ /// The provider `install` sets, to use some other way. `remembers` keeps
97
+ /// the viewer's choice across launches (in the standard user defaults).
98
+ @MainActor
99
+ public static func provider(
100
+ _ models: [Model],
101
+ directory: URL? = nil,
102
+ allowsCellularAccess: Bool = true,
103
+ remembers: Bool = true
104
+ ) -> any LanguageModelProvider {
105
+ precondition(!models.isEmpty, "LocalLanguageModel: no models")
106
+ let directory = directory ?? URL.applicationSupportDirectory.appendingPathComponent("SwiftBrowserLocalModel", isDirectory: true)
107
+ return LlamaProvider(
108
+ models: models,
109
+ stores: models.map { ModelStore(model: $0, directory: directory, allowsCellularAccess: allowsCellularAccess) },
110
+ remembers: remembers
111
+ )
112
+ }
113
+
114
+ @MainActor
115
+ public static func provider(_ model: Model, directory: URL? = nil, allowsCellularAccess: Bool = true) -> any LanguageModelProvider {
116
+ provider([model], directory: directory, allowsCellularAccess: allowsCellularAccess, remembers: false)
117
+ }
118
+ }
119
+
120
+ /// The model's file: on the device, or on its way.
121
+ @MainActor
122
+ final class ModelStore {
123
+ let model: LocalLanguageModel.Model
124
+ let file: URL
125
+ private let directory: URL
126
+ private let session: URLSession
127
+ private var download: Task<Void, Never>?
128
+ private var watcher: DownloadWatcher?
129
+ /// Why the last download failed; the next request for availability tries again.
130
+ private(set) var failure: Error?
131
+
132
+ init(model: LocalLanguageModel.Model, directory: URL, allowsCellularAccess: Bool) {
133
+ self.model = model
134
+ self.directory = directory
135
+ file = directory.appendingPathComponent(model.fileName)
136
+ let configuration = URLSessionConfiguration.default
137
+ configuration.allowsCellularAccess = allowsCellularAccess
138
+ configuration.timeoutIntervalForResource = 6 * 60 * 60
139
+ session = URLSession(configuration: configuration)
140
+ }
141
+
142
+ var isReady: Bool {
143
+ FileManager.default.fileExists(atPath: file.path)
144
+ }
145
+
146
+ var isFetching: Bool {
147
+ download != nil
148
+ }
149
+
150
+ /// How much of the file has come, from 0 to 1.
151
+ var progress: Double {
152
+ guard let task = watcher?.task else { return 0 }
153
+ let expected = task.countOfBytesExpectedToReceive > 0 ? task.countOfBytesExpectedToReceive : model.bytes ?? 0
154
+ guard expected > 0 else { return 0 }
155
+ return min(1, Double(task.countOfBytesReceived) / Double(expected))
156
+ }
157
+
158
+ /// Starts fetching the file, unless it is here or on its way.
159
+ func fetch() {
160
+ guard !isReady, download == nil else { return }
161
+ failure = nil
162
+ let watcher = DownloadWatcher()
163
+ self.watcher = watcher
164
+ download = Task { @MainActor in
165
+ do {
166
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
167
+ let (temporary, response) = try await session.download(from: model.url, delegate: watcher)
168
+ if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
169
+ throw LanguageModelError(.unavailable, "the model's download answered \(http.statusCode)")
170
+ }
171
+ let size = (try FileManager.default.attributesOfItem(atPath: temporary.path)[.size] as? NSNumber)?.int64Value
172
+ if let bytes = model.bytes, size != bytes {
173
+ try? FileManager.default.removeItem(at: temporary)
174
+ throw LanguageModelError(.unavailable, "the model's download was \(size ?? 0) bytes, not \(bytes)")
175
+ }
176
+ try? FileManager.default.removeItem(at: file)
177
+ try FileManager.default.moveItem(at: temporary, to: file)
178
+ var values = URLResourceValues()
179
+ values.isExcludedFromBackup = true
180
+ var kept = file
181
+ try? kept.setResourceValues(values)
182
+ failure = nil
183
+ } catch {
184
+ if !Task.isCancelled { failure = error }
185
+ }
186
+ download = nil
187
+ self.watcher = nil
188
+ }
189
+ }
190
+
191
+ /// Stops a download under way and removes the file.
192
+ func remove() async {
193
+ if let download {
194
+ download.cancel()
195
+ await download.value
196
+ }
197
+ failure = nil
198
+ try? FileManager.default.removeItem(at: file)
199
+ }
200
+ }
201
+
202
+ /// Hears which task a download made, so its progress can be read.
203
+ private final class DownloadWatcher: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
204
+ private let lock = NSLock()
205
+ private var current: URLSessionTask?
206
+
207
+ var task: URLSessionTask? {
208
+ lock.withLock { current }
209
+ }
210
+
211
+ func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
212
+ lock.withLock { current = task }
213
+ }
214
+ }
215
+
216
+ /// The models as `LanguageModels.fallback`: the chosen one available once
217
+ /// its file is here, loaded once for every session.
218
+ @MainActor
219
+ final class LlamaProvider: LanguageModelProvider {
220
+ let models: [LocalLanguageModel.Model]
221
+ private let stores: [String: ModelStore]
222
+ private let remembers: Bool
223
+ private static let selectionKey = "SwiftBrowserLocalModel.selected"
224
+ private(set) var selected: String
225
+ /// The loaded model and which it is.
226
+ private var engine: (id: String, task: Task<LlamaEngine, Error>)?
227
+
228
+ init(models: [LocalLanguageModel.Model], stores: [ModelStore], remembers: Bool) {
229
+ self.models = models
230
+ self.stores = Dictionary(uniqueKeysWithValues: stores.map { ($0.model.fileName, $0) })
231
+ self.remembers = remembers
232
+ let remembered = remembers ? UserDefaults.standard.string(forKey: Self.selectionKey) : nil
233
+ selected = models.first { $0.fileName == remembered }?.fileName ?? models[0].fileName
234
+ }
235
+
236
+ /// The chosen model's file.
237
+ var store: ModelStore { stores[selected]! }
238
+
239
+ func availability() async -> LanguageModelAvailability {
240
+ store.isReady ? .available : .unavailable(.modelNotReady)
241
+ }
242
+
243
+ var downloadState: LanguageModelDownload? {
244
+ Self.state(of: store)
245
+ }
246
+
247
+ var choices: [LanguageModelChoice] {
248
+ models.map { LanguageModelChoice(id: $0.fileName, name: $0.name, summary: $0.summary, download: Self.state(of: stores[$0.fileName]!)) }
249
+ }
250
+
251
+ var selectedChoice: String? { selected }
252
+
253
+ func select(_ id: String) {
254
+ guard stores[id] != nil, id != selected else { return }
255
+ selected = id
256
+ if remembers { UserDefaults.standard.set(id, forKey: Self.selectionKey) }
257
+ // The model chosen before goes once a response under way lets it go.
258
+ engine = nil
259
+ }
260
+
261
+ private static func state(of store: ModelStore) -> LanguageModelDownload {
262
+ LanguageModelDownload(
263
+ size: store.model.bytes,
264
+ isDownloaded: store.isReady,
265
+ progress: store.isFetching ? store.progress : nil,
266
+ failure: store.failure.map { ($0 as? LanguageModelError)?.message ?? $0.localizedDescription }
267
+ )
268
+ }
269
+
270
+ func startDownload() {
271
+ store.fetch()
272
+ }
273
+
274
+ func deleteDownload() async throws {
275
+ // The loaded model goes once a response under way has let it go.
276
+ if engine?.id == selected { engine = nil }
277
+ await store.remove()
278
+ }
279
+
280
+ func makeSession(instructions: String?, tools: [LanguageModelTool]) throws -> any LanguageModelProviderSession {
281
+ LlamaSession(provider: self, instructions: instructions, tools: tools)
282
+ }
283
+
284
+ /// The chosen model, loading it the first time (off the main thread).
285
+ func loaded() async throws -> LlamaEngine {
286
+ if let engine, engine.id == selected { return try await engine.task.value }
287
+ let id = selected
288
+ let path = store.file.path
289
+ let model = store.model
290
+ let loading = Task.detached(priority: .userInitiated) {
291
+ try LlamaEngine(path: path, contextLength: model.contextLength, gpuLayers: model.gpuLayers)
292
+ }
293
+ engine = (id, loading)
294
+ do {
295
+ return try await loading.value
296
+ } catch {
297
+ if engine?.id == id { engine = nil }
298
+ throw error
299
+ }
300
+ }
301
+ }
302
+
303
+ /// One conversation: its messages, each prompt answered after the ones
304
+ /// before. Tools are offered in the instructions: the model calls one by
305
+ /// answering with just `{"tool": name, "arguments": {...}}`, the session runs
306
+ /// it and hands the model the result, then lets it answer (a few rounds at
307
+ /// most); a reply that starts as such an object is held back until it is
308
+ /// clear whether it is a call.
309
+ @MainActor
310
+ final class LlamaSession: LanguageModelProviderSession {
311
+ private let provider: LlamaProvider
312
+ private let tools: [LanguageModelTool]
313
+ private(set) var messages: [LlamaEngine.Message]
314
+ /// How many tool calls one prompt may make before the model must answer.
315
+ nonisolated static let toolRounds = 4
316
+
317
+ init(provider: LlamaProvider, instructions: String?, tools: [LanguageModelTool]) {
318
+ self.provider = provider
319
+ self.tools = tools
320
+ let system = Self.systemPrompt(instructions: instructions, tools: tools)
321
+ messages = system.map { [LlamaEngine.Message(role: "system", content: $0)] } ?? []
322
+ }
323
+
324
+ func prewarm() {
325
+ let provider = provider
326
+ Task { _ = try? await provider.loaded() }
327
+ }
328
+
329
+ func streamResponse(to prompt: String, options: LanguageModelOptions) -> AsyncThrowingStream<String, Error> {
330
+ let provider = provider
331
+ let tools = tools
332
+ let asked = messages + [LlamaEngine.Message(role: "user", content: prompt)]
333
+ return AsyncThrowingStream { continuation in
334
+ let cancelled = CancelFlag()
335
+ let task = Task { @MainActor [weak self] in
336
+ do {
337
+ let engine = try await provider.loaded()
338
+ var conversation = asked
339
+ var reply = ""
340
+ for round in 0...Self.toolRounds {
341
+ let offersTools = !tools.isEmpty && round < Self.toolRounds
342
+ reply = try await engine.generate(conversation, options: options, cancelled: cancelled) { text in
343
+ // What may be a tool call is not shown.
344
+ if !offersTools || !Self.mayBeCall(text) { continuation.yield(text) }
345
+ }
346
+ guard offersTools, let call = Self.toolCall(in: reply), let tool = tools.first(where: { $0.name == call.name }) else { break }
347
+ let output: String
348
+ do {
349
+ output = try await tool.call(call.arguments)
350
+ } catch {
351
+ output = "The tool failed: \(error)"
352
+ }
353
+ conversation += [
354
+ LlamaEngine.Message(role: "assistant", content: reply),
355
+ LlamaEngine.Message(role: engine.toolRole, content: output),
356
+ ]
357
+ reply = ""
358
+ }
359
+ if !tools.isEmpty, Self.mayBeCall(reply) { continuation.yield(reply) }
360
+ // What the viewer saw is what the conversation goes on from, a stopped reply included.
361
+ if !reply.isEmpty { self?.messages = conversation + [LlamaEngine.Message(role: "assistant", content: reply)] }
362
+ continuation.finish()
363
+ } catch {
364
+ continuation.finish(throwing: error)
365
+ }
366
+ }
367
+ continuation.onTermination = { _ in
368
+ cancelled.cancel()
369
+ task.cancel()
370
+ }
371
+ }
372
+ }
373
+
374
+ func close() {}
375
+
376
+ /// The instructions, with the tools described after them.
377
+ nonisolated static func systemPrompt(instructions: String?, tools: [LanguageModelTool]) -> String? {
378
+ guard !tools.isEmpty else { return instructions }
379
+ let list = tools.map { tool in
380
+ "- \(tool.name): \(tool.description) Arguments (JSON schema): \(tool.parameters.json)"
381
+ }.joined(separator: "\n")
382
+ let guide = """
383
+ You can use these tools:
384
+ \(list)
385
+ To use a tool, reply with only a JSON object and nothing else: {"tool": "<name>", "arguments": {...}}. \
386
+ You will then get the tool's result and can answer. Use a tool only when it helps; otherwise answer directly.
387
+ """
388
+ return [instructions, guide].compactMap { $0 }.joined(separator: "\n\n")
389
+ }
390
+
391
+ /// Whether a reply so far could still turn out to be a tool call.
392
+ nonisolated static func mayBeCall(_ text: String) -> Bool {
393
+ let trimmed = text.drop(while: \.isWhitespace)
394
+ return trimmed.hasPrefix("{") || trimmed.hasPrefix("```")
395
+ }
396
+
397
+ /// The tool call a reply makes, if it is one: `{"tool"|"name": …, "arguments"|"parameters": {…}}`,
398
+ /// alone or in a code fence.
399
+ nonisolated static func toolCall(in reply: String) -> (name: String, arguments: JSONValue)? {
400
+ var text = reply.trimmingCharacters(in: .whitespacesAndNewlines)
401
+ if text.hasPrefix("```") {
402
+ text = text.drop(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
403
+ if text.hasSuffix("```") { text = String(text.dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines) }
404
+ }
405
+ guard text.hasPrefix("{"), let object = (try? JSONValue.parse(text))?.object else { return nil }
406
+ guard let name = (object["tool"] ?? object["name"])?.string else { return nil }
407
+ return (name, object["arguments"] ?? object["parameters"] ?? .object([:]))
408
+ }
409
+ }