@xybrid/react-native 0.10.0-rc1

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 (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +298 -0
  3. package/android/build.gradle +61 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/ai/xybrid/reactnative/XybridCodec.kt +481 -0
  6. package/android/src/main/java/ai/xybrid/reactnative/XybridHandles.kt +139 -0
  7. package/android/src/main/java/ai/xybrid/reactnative/XybridModule.kt +665 -0
  8. package/android/src/main/java/ai/xybrid/reactnative/XybridPackage.kt +27 -0
  9. package/ios/XybridCodec.swift +508 -0
  10. package/ios/XybridHandles.swift +191 -0
  11. package/ios/XybridModule.mm +494 -0
  12. package/ios/XybridModuleImpl.swift +887 -0
  13. package/ios/XybridSwift/Xybrid.swift +1401 -0
  14. package/ios/XybridSwift/xybrid_bolt.swift +3198 -0
  15. package/ios/xybrid_natives.rb +178 -0
  16. package/lib/commonjs/NativeXybrid.js +24 -0
  17. package/lib/commonjs/base64.js +45 -0
  18. package/lib/commonjs/cancel.js +35 -0
  19. package/lib/commonjs/context.js +67 -0
  20. package/lib/commonjs/envelope.js +79 -0
  21. package/lib/commonjs/errors.js +61 -0
  22. package/lib/commonjs/index.js +59 -0
  23. package/lib/commonjs/model.js +307 -0
  24. package/lib/commonjs/pipeline.js +55 -0
  25. package/lib/commonjs/presets.js +32 -0
  26. package/lib/commonjs/session.js +76 -0
  27. package/lib/commonjs/types.js +10 -0
  28. package/lib/commonjs/wire.js +285 -0
  29. package/lib/commonjs/xybrid.js +133 -0
  30. package/lib/module/NativeXybrid.js +22 -0
  31. package/lib/module/base64.js +41 -0
  32. package/lib/module/cancel.js +29 -0
  33. package/lib/module/context.js +60 -0
  34. package/lib/module/envelope.js +73 -0
  35. package/lib/module/errors.js +56 -0
  36. package/lib/module/index.js +36 -0
  37. package/lib/module/model.js +298 -0
  38. package/lib/module/pipeline.js +48 -0
  39. package/lib/module/presets.js +27 -0
  40. package/lib/module/session.js +69 -0
  41. package/lib/module/types.js +9 -0
  42. package/lib/module/wire.js +271 -0
  43. package/lib/module/xybrid.js +127 -0
  44. package/lib/typescript/NativeXybrid.d.ts +68 -0
  45. package/lib/typescript/NativeXybrid.d.ts.map +1 -0
  46. package/lib/typescript/base64.d.ts +5 -0
  47. package/lib/typescript/base64.d.ts.map +1 -0
  48. package/lib/typescript/cancel.d.ts +24 -0
  49. package/lib/typescript/cancel.d.ts.map +1 -0
  50. package/lib/typescript/context.d.ts +40 -0
  51. package/lib/typescript/context.d.ts.map +1 -0
  52. package/lib/typescript/envelope.d.ts +51 -0
  53. package/lib/typescript/envelope.d.ts.map +1 -0
  54. package/lib/typescript/errors.d.ts +12 -0
  55. package/lib/typescript/errors.d.ts.map +1 -0
  56. package/lib/typescript/index.d.ts +28 -0
  57. package/lib/typescript/index.d.ts.map +1 -0
  58. package/lib/typescript/model.d.ts +211 -0
  59. package/lib/typescript/model.d.ts.map +1 -0
  60. package/lib/typescript/pipeline.d.ts +35 -0
  61. package/lib/typescript/pipeline.d.ts.map +1 -0
  62. package/lib/typescript/presets.d.ts +21 -0
  63. package/lib/typescript/presets.d.ts.map +1 -0
  64. package/lib/typescript/session.d.ts +50 -0
  65. package/lib/typescript/session.d.ts.map +1 -0
  66. package/lib/typescript/types.d.ts +313 -0
  67. package/lib/typescript/types.d.ts.map +1 -0
  68. package/lib/typescript/wire.d.ts +130 -0
  69. package/lib/typescript/wire.d.ts.map +1 -0
  70. package/lib/typescript/xybrid.d.ts +89 -0
  71. package/lib/typescript/xybrid.d.ts.map +1 -0
  72. package/package.json +88 -0
  73. package/react-native-xybrid.podspec +50 -0
  74. package/src/NativeXybrid.ts +131 -0
  75. package/src/base64.ts +43 -0
  76. package/src/cancel.ts +47 -0
  77. package/src/context.ts +70 -0
  78. package/src/envelope.ts +115 -0
  79. package/src/errors.ts +67 -0
  80. package/src/index.ts +91 -0
  81. package/src/model.ts +408 -0
  82. package/src/pipeline.ts +66 -0
  83. package/src/presets.ts +36 -0
  84. package/src/session.ts +76 -0
  85. package/src/types.ts +362 -0
  86. package/src/wire.ts +408 -0
  87. package/src/xybrid.ts +176 -0
@@ -0,0 +1,1401 @@
1
+ //
2
+ // Xybrid.swift
3
+ // Xybrid SDK for iOS/macOS
4
+ //
5
+ // Hand-written wrappers + a compatibility shim that smooths over the
6
+ // uniffi → bolt migration. The BoltFFI-generated bindings live in
7
+ // `xybrid_bolt.swift` alongside this file; both compile into the same
8
+ // Swift module so consumers `import Xybrid` and see one surface.
9
+ //
10
+ // For full API documentation, see https://docs.xybrid.dev/sdk/swift
11
+ //
12
+
13
+ import Foundation
14
+ #if os(iOS)
15
+ import UIKit
16
+ #endif
17
+
18
+ // MARK: - SDK Initialization
19
+
20
+ // The generated free functions below share their names with the `Xybrid`
21
+ // members that forward to them, and inside an enum body an unqualified call
22
+ // resolves to the static member first — i.e. to itself. The module name can't
23
+ // disambiguate either, because this module is also called `Xybrid`. Binding
24
+ // them here, at file scope, where the enum's members are not in scope, is the
25
+ // one place the global is reachable by name.
26
+ private let boltReleaseMemory = releaseMemory
27
+ private let boltSetAutoRelease = setAutoRelease
28
+ private let boltIsAutoReleaseEnabled = isAutoReleaseEnabled
29
+ private let boltSetSpeculativeCloud = setSpeculativeCloud
30
+ private let boltIsSpeculativeCloudEnabled = isSpeculativeCloudEnabled
31
+ private let boltHasApiKey = hasApiKey
32
+ private let boltSetProviderApiKey = setProviderApiKey
33
+
34
+ /// Main entry point for the Xybrid SDK on iOS/macOS.
35
+ ///
36
+ /// Call `Xybrid.initialize()` once before using any other Xybrid functionality.
37
+ /// It registers the binding identifier so registry calls are attributed to the
38
+ /// Swift SDK, and is safe to call multiple times — subsequent calls are no-ops.
39
+ ///
40
+ /// Inference runs on-device whether or not you authenticate. Pass an `apiKey`
41
+ /// to start the telemetry exporter and see your runs on the dashboard — get a
42
+ /// free key at https://dashboard.xybrid.dev.
43
+ ///
44
+ /// ```swift
45
+ /// // Anonymous — local inference, telemetry disabled
46
+ /// Xybrid.initialize()
47
+ ///
48
+ /// // Authenticated — telemetry flows to the dashboard
49
+ /// Xybrid.initialize(apiKey: ProcessInfo.processInfo.environment["XYBRID_API_KEY"])
50
+ /// ```
51
+ public enum Xybrid {
52
+ private static let initLock = NSLock()
53
+ nonisolated(unsafe) private static var initialized = false
54
+ #if os(iOS)
55
+ // Retained so the NotificationCenter block-based observer keeps
56
+ // firing for the process lifetime. NotificationCenter holds the
57
+ // observer weakly through this token; losing the reference would
58
+ // silently drop battery updates.
59
+ nonisolated(unsafe) private static var batteryObserver: NSObjectProtocol?
60
+ #endif
61
+
62
+ /// Initialize the Xybrid runtime.
63
+ ///
64
+ /// Registers the Swift binding identifier with the SDK so that the
65
+ /// `X-Xybrid-Client` header on registry HTTP calls reports
66
+ /// `binding=swift`. Idempotent and thread-safe.
67
+ ///
68
+ /// All parameters are optional. Without an `apiKey`, the SDK runs fully
69
+ /// on-device and telemetry is disabled — the first inference logs a
70
+ /// one-shot hint pointing at the dashboard (suppress with the
71
+ /// `XYBRID_QUIET=1` environment variable). Pass `apiKey` to start the
72
+ /// platform telemetry exporter; `ingestUrl` overrides the destination
73
+ /// for a self-hosted dashboard, and `gatewayUrl` overrides the LLM
74
+ /// gateway. Get a free key at https://dashboard.xybrid.dev.
75
+ ///
76
+ /// Configuration is applied on the first call; because `initialize()` is
77
+ /// idempotent, a later call with different arguments is a no-op.
78
+ ///
79
+ /// On iOS, also enables `UIDevice` battery monitoring and subscribes
80
+ /// to `UIDevice.batteryLevelDidChangeNotification`, forwarding each
81
+ /// reading through the SDK's push-state surface so the routing
82
+ /// engine has live battery telemetry. Thermal state on Apple
83
+ /// platforms is sourced from `NSProcessInfo.thermalState` directly
84
+ /// in `xybrid-core` (no host wiring needed). On macOS, both
85
+ /// battery (IOKit) and thermal (NSProcessInfo) are in-Rust, so
86
+ /// nothing extra is registered here.
87
+ ///
88
+ /// - Parameters:
89
+ /// - apiKey: Xybrid API key. When set, starts the telemetry exporter.
90
+ /// - gatewayUrl: Optional override for the LLM gateway URL.
91
+ /// - ingestUrl: Optional override for the telemetry ingest URL.
92
+ public static func initialize(
93
+ apiKey: String? = nil,
94
+ gatewayUrl: String? = nil,
95
+ ingestUrl: String? = nil
96
+ ) {
97
+ initLock.lock()
98
+ defer { initLock.unlock() }
99
+ if initialized { return }
100
+ setBinding(binding: "swift")
101
+ configureRuntime(apiKey: apiKey, gatewayUrl: gatewayUrl, ingestUrl: ingestUrl)
102
+ // `registerPlatformObservers()` touches UIKit (`UIDevice.current`,
103
+ // `isBatteryMonitoringEnabled`) on iOS, which is main-thread-only.
104
+ // `initialize()` is documented as callable from any thread (apps
105
+ // commonly call it inside a `Task`), so hop to main when needed.
106
+ // The `initialized` guard ensures only one caller ever reaches here,
107
+ // so the deferred registration runs exactly once.
108
+ if Thread.isMainThread {
109
+ registerPlatformObservers()
110
+ } else {
111
+ DispatchQueue.main.async { registerPlatformObservers() }
112
+ }
113
+ initialized = true
114
+ }
115
+
116
+ /// Returns `true` if `initialize()` has been called.
117
+ public static var isInitialized: Bool {
118
+ initLock.lock()
119
+ defer { initLock.unlock() }
120
+ return initialized
121
+ }
122
+
123
+ /// Releases every idle loaded model's memory; returns how many were released.
124
+ ///
125
+ /// Wire this to the platform's low-memory signal:
126
+ ///
127
+ /// ```swift
128
+ /// override func didReceiveMemoryWarning() {
129
+ /// super.didReceiveMemoryWarning()
130
+ /// Xybrid.releaseMemory()
131
+ /// }
132
+ /// ```
133
+ ///
134
+ /// Models with a run in flight are skipped, and a released model reloads
135
+ /// itself the next time it is used — there is no new error to handle and
136
+ /// nothing to reload by hand.
137
+ @discardableResult
138
+ public static func releaseMemory() -> UInt32 {
139
+ boltReleaseMemory()
140
+ }
141
+
142
+ /// Enables or disables automatic model release for subsequent loads.
143
+ ///
144
+ /// When enabled, loading a model while the device reports memory pressure
145
+ /// first releases least-recently-used idle models. Off by default;
146
+ /// `releaseMemory()` works either way.
147
+ public static func setAutoRelease(_ enabled: Bool) {
148
+ boltSetAutoRelease(enabled)
149
+ }
150
+
151
+ /// Whether automatic model release is enabled process-wide.
152
+ public static var isAutoReleaseEnabled: Bool {
153
+ boltIsAutoReleaseEnabled()
154
+ }
155
+
156
+ /// Sets the process-wide default for speculative cloud serving.
157
+ ///
158
+ /// Speculation answers from the cloud gateway while a registry model's
159
+ /// weights download in the background, instead of blocking on the download.
160
+ ///
161
+ /// This is the *default* for loads that do not opt in per-load;
162
+ /// ``ModelLoader/fromRegistrySpeculative(_:)`` opts in explicitly and is
163
+ /// unaffected by this toggle. Off by default. Either way, speculation also
164
+ /// needs a resolvable API key and a model that is not already cached —
165
+ /// ``ModelLoader/willSpeculate`` reports the combined answer for a
166
+ /// specific loader.
167
+ public static func setSpeculativeCloud(_ enabled: Bool) {
168
+ boltSetSpeculativeCloud(enabled)
169
+ }
170
+
171
+ /// Whether the global speculative-cloud default is on.
172
+ public static var isSpeculativeCloudEnabled: Bool {
173
+ boltIsSpeculativeCloudEnabled()
174
+ }
175
+
176
+ /// Whether a Xybrid gateway API key is resolvable, from either
177
+ /// `initialize(apiKey:)` or the environment.
178
+ ///
179
+ /// Inference runs on-device without one; this reports whether the optional
180
+ /// platform features (cloud routing, telemetry) can engage.
181
+ public static var hasApiKey: Bool {
182
+ boltHasApiKey()
183
+ }
184
+
185
+ /// Sets the API key for a specific cloud provider.
186
+ ///
187
+ /// Separate from `initialize(apiKey:)`, which sets the Xybrid platform key.
188
+ /// Use this when routing to a provider the gateway forwards to.
189
+ public static func setProviderApiKey(provider: String, apiKey: String) {
190
+ boltSetProviderApiKey(provider, apiKey)
191
+ }
192
+
193
+ /// Aggregate storage usage across all managed model-cache areas.
194
+ public static func modelCacheStatus() throws -> XybridCacheStatus {
195
+ try cacheStatus()
196
+ }
197
+
198
+ /// Physical entries across registry, extraction, and Hugging Face caches.
199
+ ///
200
+ /// A model can appear more than once when several managed copies exist.
201
+ public static func modelCacheEntries() throws -> [XybridCacheEntry] {
202
+ try cacheEntries()
203
+ }
204
+
205
+ /// Return whether `modelId` occupies any managed model-cache entry.
206
+ public static func hasCachedModelData(_ modelId: String) throws -> Bool {
207
+ try cacheIsModelCached(modelId: modelId)
208
+ }
209
+
210
+ /// Return a preferred local path for a model, or `nil` when absent.
211
+ ///
212
+ /// Presence does not necessarily mean the model is extracted and ready.
213
+ public static func cachedModelPath(_ modelId: String) throws -> String? {
214
+ try cacheModelPath(modelId: modelId)
215
+ }
216
+
217
+ /// List model IDs extracted, validated, and ready to run offline.
218
+ public static func extractedModelIds() throws -> [String] {
219
+ try cacheListExtractedModelIds()
220
+ }
221
+
222
+ /// Remove every managed cache entry for one model.
223
+ ///
224
+ /// Do not call concurrently with a load of the same model.
225
+ @discardableResult
226
+ public static func removeCachedModel(_ modelId: String) throws -> UInt32 {
227
+ try cacheRemoveModel(modelId: modelId)
228
+ }
229
+
230
+ /// Clear all managed model-cache storage.
231
+ ///
232
+ /// Do not call concurrently with any model load.
233
+ @discardableResult
234
+ public static func clearModelCache() throws -> UInt32 {
235
+ try cacheClear()
236
+ }
237
+
238
+ // MARK: Model storage, off the caller's thread
239
+ //
240
+ // Every storage call walks or deletes the cache directory on disk, which is
241
+ // too slow for the main actor once a device holds a few models. These run
242
+ // the same calls on a detached background task.
243
+
244
+ /// ``modelCacheStatus()`` without blocking the calling thread or actor.
245
+ public static func modelCacheStatusAsync() async throws -> XybridCacheStatus {
246
+ try await Task.detached { try cacheStatus() }.value
247
+ }
248
+
249
+ /// ``modelCacheEntries()`` without blocking the calling thread or actor.
250
+ public static func modelCacheEntriesAsync() async throws -> [XybridCacheEntry] {
251
+ try await Task.detached { try cacheEntries() }.value
252
+ }
253
+
254
+ /// ``hasCachedModelData(_:)`` without blocking the calling thread or actor.
255
+ public static func hasCachedModelDataAsync(_ modelId: String) async throws -> Bool {
256
+ try await Task.detached { try cacheIsModelCached(modelId: modelId) }.value
257
+ }
258
+
259
+ /// ``cachedModelPath(_:)`` without blocking the calling thread or actor.
260
+ public static func cachedModelPathAsync(_ modelId: String) async throws -> String? {
261
+ try await Task.detached { try cacheModelPath(modelId: modelId) }.value
262
+ }
263
+
264
+ /// ``extractedModelIds()`` without blocking the calling thread or actor.
265
+ public static func extractedModelIdsAsync() async throws -> [String] {
266
+ try await Task.detached { try cacheListExtractedModelIds() }.value
267
+ }
268
+
269
+ /// ``removeCachedModel(_:)`` without blocking the calling thread or actor.
270
+ ///
271
+ /// Do not call concurrently with a load of the same model.
272
+ @discardableResult
273
+ public static func removeCachedModelAsync(_ modelId: String) async throws -> UInt32 {
274
+ try await Task.detached { try cacheRemoveModel(modelId: modelId) }.value
275
+ }
276
+
277
+ /// ``clearModelCache()`` without blocking the calling thread or actor.
278
+ ///
279
+ /// Do not call concurrently with any model load.
280
+ @discardableResult
281
+ public static func clearModelCacheAsync() async throws -> UInt32 {
282
+ try await Task.detached { try cacheClear() }.value
283
+ }
284
+
285
+ private static func registerPlatformObservers() {
286
+ #if os(iOS)
287
+ let device = UIDevice.current
288
+ // Battery monitoring is opt-in on iOS — without this flag,
289
+ // `batteryLevel` returns -1.0 and the notification never fires.
290
+ device.isBatteryMonitoringEnabled = true
291
+
292
+ // Push the current value immediately so the first cache miss
293
+ // isn't blind, then keep updating on every notification.
294
+ pushBatteryLevel(device.batteryLevel)
295
+
296
+ batteryObserver = NotificationCenter.default.addObserver(
297
+ forName: UIDevice.batteryLevelDidChangeNotification,
298
+ object: nil,
299
+ queue: nil
300
+ ) { _ in
301
+ pushBatteryLevel(UIDevice.current.batteryLevel)
302
+ }
303
+ #endif
304
+ }
305
+
306
+ #if os(iOS)
307
+ /// Convert `UIDevice.batteryLevel` (Float in 0.0...1.0, or -1.0 for
308
+ /// unknown) into the SDK's `UInt8` 0..=100 representation. Negative
309
+ /// or non-finite values surface as "unknown" via [`clearBatteryLevel`]
310
+ /// so the routing engine doesn't see a fake 0% reading.
311
+ private static func pushBatteryLevel(_ level: Float) {
312
+ guard level.isFinite, level >= 0 else {
313
+ clearBatteryLevel()
314
+ return
315
+ }
316
+ let pct = (level * 100).rounded()
317
+ // Clamp defensively; iOS has been observed to report 1.01 briefly
318
+ // near a full charge during recalibration.
319
+ let bounded = max(0, min(100, Int(pct)))
320
+ setBatteryLevel(percent: UInt8(bounded))
321
+ }
322
+ #endif
323
+ }
324
+
325
+ // MARK: - Model loading
326
+
327
+ /// A model location that can be prepared without performing I/O.
328
+ public enum ModelSource: Sendable, Equatable {
329
+ /// A model resolved through the Xybrid registry.
330
+ case registry(String)
331
+
332
+ /// A local `.xyb` model bundle.
333
+ case bundle(URL)
334
+
335
+ /// A local directory containing `model_metadata.json`.
336
+ case directory(URL)
337
+
338
+ /// A Hugging Face repository (`org/repo` or `org/repo:variant`).
339
+ case huggingFace(String)
340
+
341
+ /// A registry model served from the cloud gateway while its weights
342
+ /// download in the background. See
343
+ /// ``ModelLoader/fromRegistrySpeculative(_:)``.
344
+ case registrySpeculative(String)
345
+ }
346
+
347
+ /// A cheap model reference that defers all expensive work until ``load()``.
348
+ ///
349
+ /// Creating a loader never performs network, disk, or native-runtime work.
350
+ public struct ModelLoader: Sendable {
351
+ /// The source this loader will resolve.
352
+ public let source: ModelSource
353
+
354
+ /// Create a loader for an already-described source.
355
+ public init(source: ModelSource) {
356
+ self.source = source
357
+ }
358
+
359
+ /// Create a loader for a registry model.
360
+ public static func fromRegistry(_ id: String) -> Self {
361
+ Self(source: .registry(id))
362
+ }
363
+
364
+ /// Create a loader that answers from the cloud gateway while the registry
365
+ /// weights download in the background, instead of blocking on the download.
366
+ ///
367
+ /// ``load()`` returns almost immediately with a cloud-backed model that
368
+ /// switches to on-device by itself once the download lands. Requires an API
369
+ /// key and an uncached model — otherwise it behaves exactly like
370
+ /// ``fromRegistry(_:)``, which ``willSpeculate`` reports up front. LLM/chat
371
+ /// models only.
372
+ ///
373
+ /// Track the handover with ``XybridModel/isCloudServing()``,
374
+ /// ``XybridModel/downloadStatus()`` and ``XybridModel/awaitDownload(timeoutMs:)``.
375
+ public static func fromRegistrySpeculative(_ id: String) -> Self {
376
+ Self(source: .registrySpeculative(id))
377
+ }
378
+
379
+ /// Whether ``load()`` would actually speculate: speculation is possible for
380
+ /// this source, an API key resolves, and the model is not already cached.
381
+ ///
382
+ /// Always `false` for non-speculative sources. Never touches the network.
383
+ public var willSpeculate: Bool {
384
+ guard case .registrySpeculative(let id) = source else { return false }
385
+ return willSpeculateForModel(modelId: id)
386
+ }
387
+
388
+ /// Create a loader for a local `.xyb` bundle.
389
+ public static func fromBundle(_ url: URL) -> Self {
390
+ Self(source: .bundle(url))
391
+ }
392
+
393
+ /// Create a loader for a local model directory.
394
+ public static func fromDirectory(_ url: URL) -> Self {
395
+ Self(source: .directory(url))
396
+ }
397
+
398
+ /// Create a loader for a Hugging Face repository.
399
+ public static func fromHuggingFace(_ repo: String) -> Self {
400
+ Self(source: .huggingFace(repo))
401
+ }
402
+
403
+ /// Load the model without blocking the calling task's executor.
404
+ public func load() async throws -> XybridModel {
405
+ try await Task.detached { try loadSync() }.value
406
+ }
407
+
408
+ /// Load the model synchronously.
409
+ ///
410
+ /// This may resolve registry metadata, download files, access disk, and
411
+ /// initialize the inference runtime. Do not call it from the main actor.
412
+ public func loadSync() throws -> XybridModel {
413
+ switch source {
414
+ case .registry(let id):
415
+ return try XybridModel(fromRegistry: id)
416
+ case .bundle(let url):
417
+ return try XybridModel(fromBundle: url.path)
418
+ case .directory(let url):
419
+ return try XybridModel(fromDirectory: url.path)
420
+ case .huggingFace(let repo):
421
+ return try XybridModel(fromHuggingface: repo)
422
+ case .registrySpeculative(let id):
423
+ return try XybridModel(fromRegistrySpeculative: id)
424
+ }
425
+ }
426
+
427
+ /// Start downloading this model's weights in the background, without
428
+ /// loading them.
429
+ ///
430
+ /// This is how you get a progress bar: ``load()`` blocks, so there is no
431
+ /// object to poll while it runs — this is that object. The download fills
432
+ /// the SDK cache, so the ``load()`` afterwards returns immediately.
433
+ ///
434
+ /// ```swift
435
+ /// let loader = Xybrid.model("qwen3-0.6b")
436
+ /// let download = loader.download()
437
+ /// for await status in download.progress() {
438
+ /// bar.progress = Float(status.progress)
439
+ /// label.text = "\(status.downloadedBytes) / \(status.totalBytes ?? 0)"
440
+ /// }
441
+ /// let model = try await loader.load()
442
+ /// ```
443
+ ///
444
+ /// Returns `nil` for a source with nothing to fetch — a local bundle,
445
+ /// directory, or Hugging Face repo — where ``load()`` is the whole story.
446
+ /// Cancelling the task consuming `progress()` unsubscribes from updates;
447
+ /// call ``XybridDownload/cancel()`` to stop the transfer itself.
448
+ public func download() -> XybridDownload? {
449
+ switch source {
450
+ case .registry(let id), .registrySpeculative(let id):
451
+ return XybridDownload(fromRegistry: id)
452
+ case .bundle, .directory, .huggingFace:
453
+ return nil
454
+ }
455
+ }
456
+ }
457
+
458
+ public extension Xybrid {
459
+ /// Describe a registry model without resolving, downloading, or loading it.
460
+ static func model(_ id: String) -> ModelLoader {
461
+ model(.registry(id))
462
+ }
463
+
464
+ /// Describe a model source without resolving, downloading, or loading it.
465
+ static func model(_ source: ModelSource) -> ModelLoader {
466
+ ModelLoader(source: source)
467
+ }
468
+ }
469
+
470
+ // MARK: - Public Type Re-exports
471
+
472
+ /// A loaded model ready for inference.
473
+ /// Call `run(envelope:)` to execute inference on input data.
474
+ public typealias Model = XybridModel
475
+
476
+ /// A loaded multi-stage inference pipeline.
477
+ public typealias Pipeline = XybridPipeline
478
+
479
+ // The bolt handles wrap thread-safe, `Arc`-backed Rust values (the facade's
480
+ // types are `Send + Sync`), so they are safe to move across threads and
481
+ // actors — e.g. loading or running on a `Task.detached` background executor,
482
+ // which is the recommended pattern since bolt's `load`/`run` are blocking.
483
+ // boltffi does not emit `Sendable` on generated handle types yet, so declare it
484
+ // here in the hand-written wrapper (regen-safe — never overwritten by
485
+ // `boltffi generate`, unlike `xybrid_bolt.swift`).
486
+ extension XybridModel: @unchecked Sendable {}
487
+ extension XybridPipeline: @unchecked Sendable {}
488
+
489
+ /// A pull-paced asynchronous stream of generated tokens.
490
+ ///
491
+ /// The iterator asks the native streaming session for exactly one event from
492
+ /// each call to ``AsyncIterator/next()``. A slow consumer therefore stops
493
+ /// pulling from the facade and preserves its bounded-channel backpressure
494
+ /// instead of accumulating tokens in an unbounded Swift buffer.
495
+ ///
496
+ /// The sequence is one-shot. Its first iterator owns the inference run;
497
+ /// subsequent iterators finish immediately without starting another run.
498
+ public struct XybridTokenStream: AsyncSequence, Sendable {
499
+ public typealias Element = XybridStreamToken
500
+
501
+ /// An iterator over one native streaming session.
502
+ public struct AsyncIterator: AsyncIteratorProtocol {
503
+ fileprivate var state: XybridTokenStreamState?
504
+
505
+ public mutating func next() async throws -> XybridStreamToken? {
506
+ guard let state else { return nil }
507
+ do {
508
+ let token = try await state.next()
509
+ if token == nil {
510
+ self.state = nil
511
+ }
512
+ return token
513
+ } catch {
514
+ self.state = nil
515
+ throw error
516
+ }
517
+ }
518
+ }
519
+
520
+ // Copies share one source, so claiming an iterator transfers the only
521
+ // retained session state out of every copy of the sequence.
522
+ private let source: XybridTokenStreamSource
523
+
524
+ fileprivate init(
525
+ model: XybridModel,
526
+ envelope: XybridEnvelope,
527
+ options: XybridRunOptions?
528
+ ) {
529
+ self.init(
530
+ start: {
531
+ try model.runStream(
532
+ envelope: envelope,
533
+ options: options,
534
+ cancel: XybridCancellationToken()
535
+ )
536
+ },
537
+ next: { try model.streamNext(streamId: $0) },
538
+ close: { model.streamClose(streamId: $0) }
539
+ )
540
+ }
541
+
542
+ // Closure injection keeps the pull and cancellation contract testable
543
+ // without loading a native model. This initializer remains module-internal.
544
+ init(
545
+ start: @escaping @Sendable () throws -> UInt64,
546
+ next: @escaping @Sendable (UInt64) throws -> XybridStreamEvent,
547
+ close: @escaping @Sendable (UInt64) -> Void
548
+ ) {
549
+ source = XybridTokenStreamSource(
550
+ state: XybridTokenStreamState(start: start, next: next, close: close)
551
+ )
552
+ }
553
+
554
+ public func makeAsyncIterator() -> AsyncIterator {
555
+ AsyncIterator(state: source.claimState())
556
+ }
557
+ }
558
+
559
+ private final class XybridTokenStreamSource: @unchecked Sendable {
560
+ private let lock = NSLock()
561
+ private var state: XybridTokenStreamState?
562
+
563
+ init(state: XybridTokenStreamState) {
564
+ self.state = state
565
+ }
566
+
567
+ func claimState() -> XybridTokenStreamState? {
568
+ lock.lock()
569
+ defer { lock.unlock() }
570
+ let claimedState = state
571
+ state = nil
572
+ return claimedState
573
+ }
574
+ }
575
+
576
+ private final class XybridTokenStreamState: @unchecked Sendable {
577
+ private let lock = NSLock()
578
+ private let startSession: @Sendable () throws -> UInt64
579
+ private let pullNext: @Sendable (UInt64) throws -> XybridStreamEvent
580
+ private let closeSessionHandle: @Sendable (UInt64) -> Void
581
+ private var streamId: UInt64?
582
+ private var finished = false
583
+
584
+ init(
585
+ start: @escaping @Sendable () throws -> UInt64,
586
+ next: @escaping @Sendable (UInt64) throws -> XybridStreamEvent,
587
+ close: @escaping @Sendable (UInt64) -> Void
588
+ ) {
589
+ startSession = start
590
+ pullNext = next
591
+ closeSessionHandle = close
592
+ }
593
+
594
+ deinit {
595
+ close()
596
+ }
597
+
598
+ func next() async throws -> XybridStreamToken? {
599
+ if Task.isCancelled {
600
+ close()
601
+ return nil
602
+ }
603
+
604
+ do {
605
+ let token = try await withTaskCancellationHandler {
606
+ try await Task.detached { try self.pullOne() }.value
607
+ } onCancel: {
608
+ self.close()
609
+ }
610
+ if Task.isCancelled {
611
+ close()
612
+ return nil
613
+ }
614
+ return token
615
+ } catch {
616
+ if Task.isCancelled {
617
+ close()
618
+ return nil
619
+ }
620
+ throw error
621
+ }
622
+ }
623
+
624
+ private func pullOne() throws -> XybridStreamToken? {
625
+ let id = try sessionId()
626
+ let event: XybridStreamEvent
627
+ do {
628
+ event = try pullNext(id)
629
+ } catch {
630
+ close()
631
+ throw error
632
+ }
633
+
634
+ switch event.kind {
635
+ case .token:
636
+ guard let token = event.token else {
637
+ close()
638
+ throw XybridError.inferenceError(
639
+ message: "Native stream returned a token event without a token"
640
+ )
641
+ }
642
+ return token
643
+ case .complete:
644
+ close()
645
+ return nil
646
+ }
647
+ }
648
+
649
+ private func sessionId() throws -> UInt64 {
650
+ lock.lock()
651
+ if finished {
652
+ lock.unlock()
653
+ throw CancellationError()
654
+ }
655
+ if let streamId {
656
+ lock.unlock()
657
+ return streamId
658
+ }
659
+ lock.unlock()
660
+
661
+ let openedId: UInt64
662
+ do {
663
+ openedId = try startSession()
664
+ } catch {
665
+ close()
666
+ throw error
667
+ }
668
+ lock.lock()
669
+ if finished {
670
+ lock.unlock()
671
+ closeSessionHandle(openedId)
672
+ throw CancellationError()
673
+ }
674
+ if let existingId = streamId {
675
+ lock.unlock()
676
+ // Defensive only: AsyncIteratorProtocol requires serialized next
677
+ // calls, but do not leak a session if a caller violates it.
678
+ closeSessionHandle(openedId)
679
+ return existingId
680
+ }
681
+ streamId = openedId
682
+ lock.unlock()
683
+ return openedId
684
+ }
685
+
686
+ private func close() {
687
+ lock.lock()
688
+ guard !finished else {
689
+ lock.unlock()
690
+ return
691
+ }
692
+ finished = true
693
+ let id = streamId
694
+ streamId = nil
695
+ lock.unlock()
696
+
697
+ if let id {
698
+ // Native close may wait for the worker to leave inference. A task's
699
+ // cancellation handler (and iterator deinit) runs synchronously on
700
+ // its caller, which can be the UI thread. Retain the close closure,
701
+ // not this state, until cleanup finishes on a background executor.
702
+ let closeHandle = closeSessionHandle
703
+ Task.detached { closeHandle(id) }
704
+ }
705
+ }
706
+ }
707
+
708
+ public extension XybridModel {
709
+ /// Open a live ASR session: feed microphone PCM in, read partial
710
+ /// transcripts out.
711
+ ///
712
+ /// This is the live-capture surface. ``run(envelope:)`` transcribes a
713
+ /// finished buffer; this transcribes speech as it arrives, which is what
714
+ /// dictation and live captioning need.
715
+ ///
716
+ /// Audio must be PCM **Float32, mono, 16 kHz** — converting from the
717
+ /// microphone's format is the caller's job.
718
+ ///
719
+ /// ```swift
720
+ /// let session = try model.stream()
721
+ /// Task {
722
+ /// for await partial in session.partials() {
723
+ /// label.text = partial.text
724
+ /// }
725
+ /// }
726
+ /// // from the audio callback:
727
+ /// try session.feed(samples: pcm)
728
+ /// // when the user stops talking:
729
+ /// let transcript = try session.flush()
730
+ /// ```
731
+ ///
732
+ /// - Parameter config: chunking options. The default is fixed-window
733
+ /// chunking at 16 kHz with the model's own language; pass
734
+ /// ``XybridStreamingConfig/voiceActivity(modelDir:language:threshold:)``
735
+ /// to chunk on speech boundaries instead.
736
+ /// - Throws: ``XybridError/streamingNotSupported`` if this is not an ASR
737
+ /// model, or ``XybridError/configError(message:)`` for a sample rate
738
+ /// other than 16 kHz.
739
+ func stream(
740
+ config: XybridStreamingConfig = .default
741
+ ) throws -> XybridStreamingSession {
742
+ try XybridStreamingSession(forModel: self, config: config)
743
+ }
744
+
745
+ /// Run inference with the model's default options.
746
+ ///
747
+ /// Convenience over `run(envelope:options:)` so simple call sites stay
748
+ /// one-argument; forwards `nil` options. Use the two-arg form to override
749
+ /// generation config, abort signals, or cloud-fallback behaviour.
750
+ func run(envelope: XybridEnvelope) throws -> XybridResult {
751
+ try run(envelope: envelope, options: nil)
752
+ }
753
+
754
+ /// Run inference that cannot be cancelled.
755
+ ///
756
+ /// The generated `run(envelope:options:cancel:)` takes the stop button as a
757
+ /// required argument — BoltFFI cannot express an optional handle parameter —
758
+ /// so this overload manufactures a token that is never signalled. Reach for
759
+ /// the three-argument form, or `runAsync`, when you want to stop a run.
760
+ func run(envelope: XybridEnvelope, options: XybridRunOptions?) throws -> XybridResult {
761
+ try run(envelope: envelope, options: options, cancel: XybridCancellationToken())
762
+ }
763
+
764
+ /// Context-aware run that cannot be cancelled. See `run(envelope:options:)`.
765
+ func runWithContext(
766
+ envelope: XybridEnvelope,
767
+ context: XybridConversationContext,
768
+ options: XybridRunOptions?
769
+ ) throws -> XybridResult {
770
+ try runWithContext(
771
+ envelope: envelope,
772
+ context: context,
773
+ options: options,
774
+ cancel: XybridCancellationToken()
775
+ )
776
+ }
777
+
778
+ /// Start a context-aware pull stream that cannot be cancelled.
779
+ /// See `run(envelope:options:)`.
780
+ func runStreamWithContext(
781
+ envelope: XybridEnvelope,
782
+ context: XybridConversationContext,
783
+ options: XybridRunOptions?
784
+ ) throws -> UInt64 {
785
+ try runStreamWithContext(
786
+ envelope: envelope,
787
+ context: context,
788
+ options: options,
789
+ cancel: XybridCancellationToken()
790
+ )
791
+ }
792
+
793
+ /// Start a pull-based stream that cannot be cancelled.
794
+ /// See `run(envelope:options:)`.
795
+ func runStream(envelope: XybridEnvelope, options: XybridRunOptions?) throws -> UInt64 {
796
+ try runStream(envelope: envelope, options: options, cancel: XybridCancellationToken())
797
+ }
798
+ }
799
+
800
+ public extension XybridPipeline {
801
+ /// Parse and load a pipeline without blocking the caller.
802
+ static func fromYamlAsync(_ yaml: String) async throws -> XybridPipeline {
803
+ try await Task.detached { try XybridPipeline(fromYaml: yaml) }.value
804
+ }
805
+
806
+ /// Read, parse, and load a pipeline file without blocking the caller.
807
+ static func fromFileAsync(_ url: URL) async throws -> XybridPipeline {
808
+ try await Task.detached { try XybridPipeline(fromFile: url.path) }.value
809
+ }
810
+
811
+ /// Load a pipeline bundle without blocking the caller.
812
+ static func fromBundleAsync(_ url: URL) async throws -> XybridPipeline {
813
+ try await Task.detached { try XybridPipeline(fromBundle: url.path) }.value
814
+ }
815
+
816
+ /// Run every stage with default options.
817
+ ///
818
+ /// Convenience over `run(envelope:options:)`. The first run downloads any
819
+ /// model the pipeline still needs, so prefer ``runAsync(envelope:options:)``
820
+ /// off the main actor.
821
+ func run(envelope: XybridEnvelope) throws -> XybridPipelineResult {
822
+ try run(envelope: envelope, options: nil)
823
+ }
824
+
825
+ /// Run every stage without blocking the calling thread or actor.
826
+ ///
827
+ /// Of `options`, only `correlationId` applies to a pipeline run; setting
828
+ /// `generationConfig` or `abortOn` throws ``XybridError/configError(message:)``.
829
+ func runAsync(
830
+ envelope: XybridEnvelope,
831
+ options: XybridRunOptions? = nil
832
+ ) async throws -> XybridPipelineResult {
833
+ try await Task.detached { try self.run(envelope: envelope, options: options) }.value
834
+ }
835
+ }
836
+
837
+ // MARK: - Pipeline result ergonomics
838
+ //
839
+ // A pipeline run returns every stage's output, not only the last one, so a
840
+ // voice assistant can show what it heard and what it answered while it plays
841
+ // the audio:
842
+ //
843
+ // let result = try await pipeline.runAsync(envelope: .audio(pcmData: pcm))
844
+ // transcript.text = result.stage("asr")?.text
845
+ // reply.text = result.stage("llm")?.text
846
+ // try player.play(result.audioBytes)
847
+
848
+ public extension XybridPipelineResult {
849
+ /// Final text payload, if the last stage produced text. `nil` otherwise.
850
+ var text: String? { envelope.textPayload }
851
+
852
+ /// Final audio bytes, if the last stage produced audio. `nil` otherwise.
853
+ var audioBytes: Data? { envelope.audioPayload }
854
+
855
+ /// The whole run's latency as a `TimeInterval` in seconds.
856
+ var latency: TimeInterval { TimeInterval(latencyMs) / 1000.0 }
857
+
858
+ /// The stage with this identifier — the YAML `id:` — if it ran.
859
+ func stage(_ id: String) -> XybridStageResult? {
860
+ stages.first { $0.stageId == id }
861
+ }
862
+ }
863
+
864
+ public extension XybridStageResult {
865
+ /// This stage's text output — an ASR transcript, an LLM reply. `nil` for
866
+ /// any other payload.
867
+ var text: String? { envelope.textPayload }
868
+
869
+ /// This stage's audio output, if it produced audio. `nil` otherwise.
870
+ var audioBytes: Data? { envelope.audioPayload }
871
+
872
+ /// This stage's latency as a `TimeInterval` in seconds.
873
+ var latency: TimeInterval { TimeInterval(latencyMs) / 1000.0 }
874
+ }
875
+
876
+ private extension XybridEnvelope {
877
+ var textPayload: String? {
878
+ if case .text(let text) = kind { return text }
879
+ return nil
880
+ }
881
+
882
+ var audioPayload: Data? {
883
+ if case .audio(let bytes) = kind { return bytes }
884
+ return nil
885
+ }
886
+ }
887
+
888
+ // MARK: - Async conveniences
889
+ //
890
+ // bolt's `load` and `run` are synchronous + blocking. These wrappers restore the
891
+ // pre-migration `async` API shape: each runs the blocking call on a detached
892
+ // background executor (`Task.detached`), so callers `await` without blocking the
893
+ // calling thread or actor — exactly what UI code wants for model load / inference.
894
+ //
895
+ // (boltffi *can* export `async fn` natively, but the SDK's async path uses tokio
896
+ // `spawn_blocking`, which panics without an ambient tokio runtime context that
897
+ // boltffi's own future driver does not establish. Wrapping the synchronous call
898
+ // off-thread is therefore the correct, low-risk way to surface async today.)
899
+ public extension XybridModel {
900
+ /// Load a model from the xybrid registry without blocking the caller.
901
+ @available(*, deprecated, message: "Use Xybrid.model(id).load()")
902
+ static func fromRegistryAsync(_ id: String) async throws -> XybridModel {
903
+ try await Xybrid.model(id).load()
904
+ }
905
+
906
+ /// Load a model from a local directory without blocking the caller.
907
+ @available(*, deprecated, message: "Use Xybrid.model(.directory(url)).load()")
908
+ static func fromDirectoryAsync(_ path: String) async throws -> XybridModel {
909
+ try await Xybrid.model(.directory(URL(fileURLWithPath: path))).load()
910
+ }
911
+
912
+ /// Load a model from a local `.xyb` bundle without blocking the caller.
913
+ @available(*, deprecated, message: "Use Xybrid.model(.bundle(url)).load()")
914
+ static func fromBundleAsync(_ path: String) async throws -> XybridModel {
915
+ try await Xybrid.model(.bundle(URL(fileURLWithPath: path))).load()
916
+ }
917
+
918
+ /// Resolve and load a model from a HuggingFace repo without blocking the caller.
919
+ @available(*, deprecated, message: "Use Xybrid.model(.huggingFace(repo)).load()")
920
+ static func fromHuggingfaceAsync(_ repo: String) async throws -> XybridModel {
921
+ try await Xybrid.model(.huggingFace(repo)).load()
922
+ }
923
+
924
+ /// Run inference without blocking the calling thread or actor.
925
+ ///
926
+ /// Honours Swift's structured concurrency: cancelling the surrounding
927
+ /// `Task` signals the native stop button. The run then returns or throws
928
+ /// whatever the backend reports for a cancelled run — it does not surface
929
+ /// `CancellationError`.
930
+ ///
931
+ /// Cancellation is checked at token boundaries **while streaming**. A batch
932
+ /// run is only cancellable before generation starts: once the backend is
933
+ /// producing, `run_with_options` has no token-aware path to stop it, so the
934
+ /// call finishes normally. Use the streaming surface when a mid-flight stop
935
+ /// button matters.
936
+ func runAsync(
937
+ envelope: XybridEnvelope,
938
+ options: XybridRunOptions? = nil
939
+ ) async throws -> XybridResult {
940
+ let cancel = XybridCancellationToken()
941
+ return try await withTaskCancellationHandler {
942
+ try await Task.detached {
943
+ try self.run(envelope: envelope, options: options, cancel: cancel)
944
+ }.value
945
+ } onCancel: {
946
+ // Runs on the cancelling thread; `cancel()` is safe from any thread
947
+ // and is a no-op once the run has finished.
948
+ cancel.cancel()
949
+ }
950
+ }
951
+
952
+ /// Warm up the model without blocking the calling thread or actor.
953
+ func warmupAsync() async throws {
954
+ try await Task.detached { try self.warmup() }.value
955
+ }
956
+
957
+ /// Unload the model, freeing its memory, without blocking the calling
958
+ /// thread or actor.
959
+ func unloadAsync() async throws {
960
+ try await Task.detached { try self.unload() }.value
961
+ }
962
+
963
+ /// Stream inference token-by-token as an async sequence.
964
+ ///
965
+ /// Yields each `XybridStreamToken` as it is generated and finishes when
966
+ /// generation completes; throws ``XybridError`` if the run fails
967
+ /// mid-stream. Cancelling the consuming task — or breaking out of the
968
+ /// `for try await` loop — aborts generation at the next token boundary:
969
+ /// the underlying streaming session is closed, which unwinds the backend
970
+ /// instead of running to `max_tokens`. Ergonomic wrapper over the
971
+ /// pull-based session API (``runStream(envelope:options:)`` /
972
+ /// ``streamNext(streamId:)`` / ``streamClose(streamId:)``).
973
+ ///
974
+ /// ```swift
975
+ /// for try await token in model.streamTokens(envelope: env) {
976
+ /// print(token.token, terminator: "")
977
+ /// }
978
+ /// ```
979
+ func streamTokens(
980
+ envelope: XybridEnvelope,
981
+ options: XybridRunOptions? = nil
982
+ ) -> XybridTokenStream {
983
+ XybridTokenStream(model: self, envelope: envelope, options: options)
984
+ }
985
+ }
986
+
987
+ /// Input data for model inference.
988
+ /// Use `.audio(pcmData:sampleRate:channels:)` or `.text(_:voice:speed:)`.
989
+ public typealias Envelope = XybridEnvelope
990
+
991
+ /// The result of a model inference operation.
992
+ public typealias Result = XybridResult
993
+
994
+ /// Errors that can occur during model loading or inference.
995
+ public typealias XybridSDKError = XybridError
996
+
997
+ /// Voice metadata for TTS models.
998
+ public typealias VoiceInfo = XybridVoiceInfo
999
+
1000
+ /// Generation parameters for LLM inference (temperature, top_p, max_tokens, etc.).
1001
+ public typealias GenerationConfig = XybridGenerationConfig
1002
+
1003
+ /// A tool the model may ask to call.
1004
+ public typealias ToolDefinition = XybridToolDefinition
1005
+
1006
+ /// One tool call the model emitted, from `XybridResult.toolCalls`.
1007
+ public typealias ToolCall = XybridToolCall
1008
+
1009
+ /// The outcome of running one tool, fed back with `Envelope.toolResults`.
1010
+ public typealias ToolResult = XybridToolResult
1011
+
1012
+ /// One token emitted by a streaming run. The terminal token carries the
1013
+ /// turn's `toolCalls` and `rawText`.
1014
+ public typealias StreamToken = XybridStreamToken
1015
+
1016
+ // MARK: - GenerationConfig ergonomics
1017
+ //
1018
+ // The generated memberwise init takes every field positionally, so setting one
1019
+ // parameter means spelling out all nine. These factories default the rest.
1020
+ // They're static funcs rather than a defaulted `init` because an extension
1021
+ // init with the same argument labels would collide with the generated one.
1022
+
1023
+ public extension XybridStreamingConfig {
1024
+ /// Fixed time-window chunking at the required 16 kHz, using the model's
1025
+ /// own language. The starting point for dictation.
1026
+ static var `default`: XybridStreamingConfig {
1027
+ XybridStreamingConfig(
1028
+ sampleRate: 16_000,
1029
+ vad: .off,
1030
+ vadThreshold: 0.5,
1031
+ language: nil,
1032
+ audioCtx: nil
1033
+ )
1034
+ }
1035
+
1036
+ /// Chunk on speech boundaries using voice-activity detection, rather than
1037
+ /// on a fixed clock.
1038
+ ///
1039
+ /// Better transcripts for natural speech — a window cut mid-word is what
1040
+ /// makes fixed chunking stutter — at the cost of loading a small VAD
1041
+ /// model alongside the ASR one.
1042
+ ///
1043
+ /// - Parameters:
1044
+ /// - modelDir: directory holding a Silero VAD model, containing a
1045
+ /// `model.onnx`. Required: no VAD model ships with the SDK, and the
1046
+ /// engine falls back to fixed windows without one.
1047
+ /// - language: language hint such as `"en"`; `nil` uses the model default.
1048
+ /// - threshold: VAD sensitivity, 0.0–1.0. Lower catches quieter speech
1049
+ /// and more background noise with it.
1050
+ static func voiceActivity(
1051
+ modelDir: String,
1052
+ language: String? = nil,
1053
+ threshold: Float = 0.5
1054
+ ) -> XybridStreamingConfig {
1055
+ XybridStreamingConfig(
1056
+ sampleRate: 16_000,
1057
+ vad: .enabled(modelDir: modelDir),
1058
+ vadThreshold: threshold,
1059
+ language: language,
1060
+ audioCtx: nil
1061
+ )
1062
+ }
1063
+ }
1064
+
1065
+ public extension XybridGenerationConfig {
1066
+ /// Build a config, defaulting every field you don't set to the model's own
1067
+ /// default.
1068
+ static func make(
1069
+ maxTokens: UInt32? = nil,
1070
+ temperature: Float? = nil,
1071
+ topP: Float? = nil,
1072
+ minP: Float? = nil,
1073
+ topK: UInt32? = nil,
1074
+ repetitionPenalty: Float? = nil,
1075
+ stopSequences: [String] = [],
1076
+ grammar: String? = nil,
1077
+ tools: [XybridToolDefinition] = []
1078
+ ) -> XybridGenerationConfig {
1079
+ XybridGenerationConfig(
1080
+ maxTokens: maxTokens,
1081
+ temperature: temperature,
1082
+ topP: topP,
1083
+ minP: minP,
1084
+ topK: topK,
1085
+ repetitionPenalty: repetitionPenalty,
1086
+ stopSequences: stopSequences,
1087
+ grammar: grammar,
1088
+ tools: tools
1089
+ )
1090
+ }
1091
+
1092
+ /// Greedy decoding (deterministic, temperature 0). The usual choice for
1093
+ /// extraction and for tool calling, where you want the most likely tokens
1094
+ /// rather than creative sampling.
1095
+ static func greedy(
1096
+ maxTokens: UInt32? = nil,
1097
+ grammar: String? = nil,
1098
+ tools: [XybridToolDefinition] = []
1099
+ ) -> XybridGenerationConfig {
1100
+ .make(
1101
+ maxTokens: maxTokens,
1102
+ temperature: 0.0,
1103
+ topP: 1.0,
1104
+ topK: 0,
1105
+ grammar: grammar,
1106
+ tools: tools
1107
+ )
1108
+ }
1109
+
1110
+ /// Higher temperature, for more varied output.
1111
+ static func creative(
1112
+ maxTokens: UInt32? = nil,
1113
+ tools: [XybridToolDefinition] = []
1114
+ ) -> XybridGenerationConfig {
1115
+ .make(
1116
+ maxTokens: maxTokens,
1117
+ temperature: 0.9,
1118
+ topP: 0.95,
1119
+ topK: 50,
1120
+ tools: tools
1121
+ )
1122
+ }
1123
+
1124
+ /// The same config, offering `tools` to the model.
1125
+ func withTools(_ tools: [XybridToolDefinition]) -> XybridGenerationConfig {
1126
+ var copy = self
1127
+ copy.tools = tools
1128
+ return copy
1129
+ }
1130
+ }
1131
+
1132
+ // MARK: - Tool-calling ergonomics
1133
+
1134
+ public extension XybridEnvelope {
1135
+ /// The continuation envelope for the turn after the model asked for tools.
1136
+ ///
1137
+ /// One `run` is one model turn, so the tool loop lives in your code: run a
1138
+ /// request carrying tools, execute every `XybridResult.toolCalls` entry,
1139
+ /// then run this envelope to feed the outcomes back. Run the continuation
1140
+ /// with the same tools as the original turn so the executor rebuilds an
1141
+ /// identical chat prefix.
1142
+ ///
1143
+ /// - Parameters:
1144
+ /// - userText: The original user message of the turn being continued.
1145
+ /// - priorAssistantText: That turn's raw output text, tool-call block
1146
+ /// included — i.e. `XybridResult.text` verbatim.
1147
+ /// - results: Tool outcomes, in call order.
1148
+ /// - Throws: `XybridError` if a result's content isn't valid JSON.
1149
+ static func toolResults(
1150
+ _ userText: String,
1151
+ priorAssistantText: String,
1152
+ results: [XybridToolResult]
1153
+ ) throws -> XybridEnvelope {
1154
+ try toolResultsEnvelope(
1155
+ userText: userText,
1156
+ priorAssistantText: priorAssistantText,
1157
+ results: results
1158
+ )
1159
+ }
1160
+ }
1161
+
1162
+ // MARK: - XybridResult compatibility shim
1163
+ //
1164
+ // The bolt-generated `XybridResult` carries an `envelope` whose `kind` is
1165
+ // a tagged enum (`.text(text:)`, `.audio(bytes:)`, `.embedding(values:)`).
1166
+ // The previous uniffi-generated `XybridResult` flattened these into
1167
+ // optional fields (`text`, `audioBytes`, `embedding`) plus a `success`
1168
+ // flag. Consumer code (notably the iOS example) reads those flat
1169
+ // fields, so we mirror them as computed properties on the bolt type.
1170
+
1171
+ public extension XybridResult {
1172
+ /// `true` for any result returned from `XybridModel.run`. Bolt
1173
+ /// surfaces failures as `throws` rather than a `success` flag; this
1174
+ /// flag stays at `true` for shape compatibility with the previous
1175
+ /// uniffi shape.
1176
+ var success: Bool { true }
1177
+
1178
+ /// `true` if the result carries no output (`OutputType.unknown`).
1179
+ var isFailure: Bool { outputType == .unknown }
1180
+
1181
+ /// Text payload, if the result is `.text`. `nil` otherwise.
1182
+ var text: String? {
1183
+ if case .text(let text) = envelope.kind { return text }
1184
+ return nil
1185
+ }
1186
+
1187
+ /// Audio bytes, if the result is `.audio`. `nil` otherwise.
1188
+ ///
1189
+ /// Returns `Data` (not `[UInt8]`) because that's what BoltFFI emits
1190
+ /// for `Vec<u8>` — gives Swift consumers the standard `Data` API
1191
+ /// (slicing, hashing, AVAudioPlayer init) without a copy.
1192
+ var audioBytes: Data? {
1193
+ if case .audio(let bytes) = envelope.kind { return bytes }
1194
+ return nil
1195
+ }
1196
+
1197
+ /// Embedding vector, if the result is `.embedding`. `nil` otherwise.
1198
+ var embedding: [Float]? {
1199
+ if case .embedding(let values) = envelope.kind { return values }
1200
+ return nil
1201
+ }
1202
+
1203
+ /// The latency as a `TimeInterval` in seconds.
1204
+ var latency: TimeInterval { TimeInterval(latencyMs) / 1000.0 }
1205
+
1206
+ /// `true` if the model asked to call at least one tool this turn.
1207
+ var hasToolCalls: Bool { !toolCalls.isEmpty }
1208
+ }
1209
+
1210
+ // MARK: - XybridStreamToken ergonomics
1211
+
1212
+ public extension XybridStreamToken {
1213
+ /// `true` if this token carries tool calls to execute.
1214
+ ///
1215
+ /// Only ever true on the terminal token. Tool-call blocks are suppressed
1216
+ /// from the streamed text, so this — not the token text — is what a
1217
+ /// streaming loop branches on.
1218
+ var hasToolCalls: Bool { !toolCalls.isEmpty }
1219
+ }
1220
+
1221
+ // MARK: - XybridEnvelope compatibility factories
1222
+ //
1223
+ // Mirrors the previous uniffi factories that constructed envelopes with
1224
+ // inline metadata. Bolt's struct uses `kind` + `metadata` arrays; these
1225
+ // helpers fold the well-known TTS / ASR metadata keys into entries.
1226
+
1227
+ public extension XybridEnvelope {
1228
+ /// Creates an audio envelope with format metadata.
1229
+ /// - Parameters:
1230
+ /// - pcmData: Raw PCM / WAV bytes.
1231
+ /// - sampleRate: Sample rate in Hz (e.g. 16000 for ASR).
1232
+ /// - channels: Number of channels (typically 1 for mono).
1233
+ static func audio(pcmData: Data, sampleRate: UInt32 = 16000, channels: UInt32 = 1) -> XybridEnvelope {
1234
+ return XybridEnvelope(
1235
+ kind: .audio(bytes: pcmData),
1236
+ metadata: [
1237
+ XybridMetadataEntry(key: "sample_rate", value: String(sampleRate)),
1238
+ XybridMetadataEntry(key: "channels", value: String(channels)),
1239
+ ]
1240
+ )
1241
+ }
1242
+
1243
+ /// Creates a text envelope for TTS with default voice.
1244
+ static func text(_ content: String) -> XybridEnvelope {
1245
+ return XybridEnvelope(kind: .text(text: content), metadata: [])
1246
+ }
1247
+
1248
+ /// Creates a text envelope for TTS with explicit voice + speed.
1249
+ static func text(_ content: String, voice: String, speed: Double = 1.0) -> XybridEnvelope {
1250
+ return text(text: content, voiceId: voice, speed: speed)
1251
+ }
1252
+
1253
+ /// Mirrors the previous uniffi factory signature. Voice / speed fold
1254
+ /// into metadata entries (the executor reads them by key).
1255
+ static func text(text: String, voiceId: String?, speed: Double?) -> XybridEnvelope {
1256
+ var metadata: [XybridMetadataEntry] = []
1257
+ if let v = voiceId {
1258
+ metadata.append(XybridMetadataEntry(key: "voice_id", value: v))
1259
+ }
1260
+ if let s = speed {
1261
+ metadata.append(XybridMetadataEntry(key: "speed", value: String(s)))
1262
+ }
1263
+ return XybridEnvelope(kind: .text(text: text), metadata: metadata)
1264
+ }
1265
+
1266
+ /// Creates an embedding envelope from a float vector.
1267
+ static func embedding(data: [Float]) -> XybridEnvelope {
1268
+ return XybridEnvelope(kind: .embedding(values: data), metadata: [])
1269
+ }
1270
+
1271
+ /// Creates an encoded image envelope for vision-language models. The bytes
1272
+ /// are decode-validated on the Rust side at run time (surfacing as a
1273
+ /// `XybridError.invalidImage` for bad/oversized/unsupported input).
1274
+ /// - Parameters:
1275
+ /// - bytes: Encoded PNG, JPEG, or WebP data
1276
+ /// - format: Image format hint (`png`, `jpeg`, `jpg`, or `webp`)
1277
+ static func image(_ bytes: Data, format: String) throws -> XybridEnvelope {
1278
+ return XybridEnvelope(
1279
+ kind: .image(bytes: bytes, format: try normalizeImageFormat(format)),
1280
+ metadata: []
1281
+ )
1282
+ }
1283
+
1284
+ /// Creates a multi-part user message with text and image attachments,
1285
+ /// tagged with the `User` role.
1286
+ /// - Parameters:
1287
+ /// - text: User prompt text
1288
+ /// - images: Image envelopes created by `image(_:format:)`
1289
+ static func userMessage(_ text: String, images: [XybridEnvelope] = []) throws -> XybridEnvelope {
1290
+ guard images.allSatisfy({ envelope in
1291
+ if case .image = envelope.kind { return true }
1292
+ return false
1293
+ }) else {
1294
+ throw XybridError.configError(message: "Envelope.userMessage accepts only image envelopes")
1295
+ }
1296
+ var parts = [XybridEnvelope(kind: .text(text: text), metadata: [])]
1297
+ parts.append(contentsOf: images)
1298
+ return XybridEnvelope(
1299
+ kind: .multiPart(parts: parts),
1300
+ metadata: [XybridMetadataEntry(key: "xybrid.role", value: "user")]
1301
+ )
1302
+ }
1303
+
1304
+ private static func normalizeImageFormat(_ format: String) throws -> String {
1305
+ let normalized = format.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1306
+ switch normalized {
1307
+ case "jpg":
1308
+ return "jpeg"
1309
+ case "jpeg", "png", "webp":
1310
+ return normalized
1311
+ default:
1312
+ throw XybridError.configError(
1313
+ message: "Unsupported image format '\(format)'. Supported formats: png, jpeg, jpg, webp"
1314
+ )
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ // MARK: - XybridVoiceInfo Extensions
1320
+
1321
+ public extension XybridVoiceInfo {
1322
+ /// Returns `true` if the voice gender is male.
1323
+ var isMale: Bool { gender == "male" }
1324
+
1325
+ /// Returns `true` if the voice gender is female.
1326
+ var isFemale: Bool { gender == "female" }
1327
+ }
1328
+
1329
+ // MARK: - XybridError Extensions
1330
+
1331
+ extension XybridError: LocalizedError {
1332
+ public var errorDescription: String? {
1333
+ // BoltFFI emits enum cases in lowerCamelCase (Swift idiom) with
1334
+ // named associated values from the Rust variant fields.
1335
+ switch self {
1336
+ case .modelNotFound(let id):
1337
+ return "Model not found: \(id)"
1338
+ case .directoryNotFound(let path):
1339
+ return "Directory not found: \(path)"
1340
+ case .metadataNotFound(let path):
1341
+ return "model_metadata.json not found: \(path)"
1342
+ case .metadataInvalid(let message):
1343
+ return "model_metadata.json is invalid: \(message)"
1344
+ case .loadError(let message):
1345
+ return "Load error: \(message)"
1346
+ case .inferenceError(let message):
1347
+ return "Inference failed: \(message)"
1348
+ case .abortedForCloudFallback(let reason):
1349
+ return "Aborted for cloud fallback: \(reason)"
1350
+ case .streamingNotSupported:
1351
+ return "Streaming is not supported by this model"
1352
+ case .notLoaded:
1353
+ return "Model not loaded"
1354
+ case .configError(let message):
1355
+ return "Invalid configuration: \(message)"
1356
+ case .networkError(let message):
1357
+ return "Network error: \(message)"
1358
+ case .offline(let message):
1359
+ return "Registry unreachable: \(message)"
1360
+ case .ioError(let message):
1361
+ return "I/O error: \(message)"
1362
+ case .cacheError(let message):
1363
+ return "Cache error: \(message)"
1364
+ case .pipelineError(let message):
1365
+ return "Pipeline error: \(message)"
1366
+ case .circuitOpen(let message):
1367
+ return "Circuit breaker open: \(message)"
1368
+ case .rateLimited(let retryAfterSecs):
1369
+ return "Rate limited, retry after \(retryAfterSecs) seconds"
1370
+ case .timeout(let timeoutMs):
1371
+ return "Request timeout after \(timeoutMs)ms"
1372
+ case .missingArtifact(let message):
1373
+ return "Missing artifact: \(message)"
1374
+ case .unsupportedModelCapability(let message):
1375
+ return "Unsupported model capability: \(message)"
1376
+ case .unsupportedBackendCapability(let message):
1377
+ return "Unsupported backend capability: \(message)"
1378
+ case .invalidImage(let message):
1379
+ return "Invalid image: \(message)"
1380
+ case .cancelled(let message):
1381
+ return "Cancelled: \(message)"
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ // MARK: - FfiError Extensions
1387
+
1388
+ // `FfiError` (in the generated `xybrid_bolt.swift`) is thrown by the low-level
1389
+ // FFI paths that don't carry a typed `XybridError` wire payload — the model-load
1390
+ // inits (`fromRegistry`/`fromDirectory`/`fromBundle`/`fromHuggingface`, via
1391
+ // `boltffi_last_error_message`) and the `checkStatus` ABI guard. It already holds
1392
+ // the real Rust error string in `.message`, but as a bare `Error` struct its
1393
+ // `localizedDescription` collapses to the opaque "The operation couldn't be
1394
+ // completed. (Xybrid.FfiError error 1.)". Conforming to `LocalizedError` here
1395
+ // (regen-safe — this hand-written file is never overwritten by `boltffi
1396
+ // generate`) surfaces the actual message to callers' error UI.
1397
+ extension FfiError: LocalizedError {
1398
+ public var errorDescription: String? {
1399
+ message.isEmpty ? "Unknown FFI error" : message
1400
+ }
1401
+ }