@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.
- package/LICENSE +201 -0
- package/README.md +298 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridCodec.kt +481 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridHandles.kt +139 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridModule.kt +665 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridPackage.kt +27 -0
- package/ios/XybridCodec.swift +508 -0
- package/ios/XybridHandles.swift +191 -0
- package/ios/XybridModule.mm +494 -0
- package/ios/XybridModuleImpl.swift +887 -0
- package/ios/XybridSwift/Xybrid.swift +1401 -0
- package/ios/XybridSwift/xybrid_bolt.swift +3198 -0
- package/ios/xybrid_natives.rb +178 -0
- package/lib/commonjs/NativeXybrid.js +24 -0
- package/lib/commonjs/base64.js +45 -0
- package/lib/commonjs/cancel.js +35 -0
- package/lib/commonjs/context.js +67 -0
- package/lib/commonjs/envelope.js +79 -0
- package/lib/commonjs/errors.js +61 -0
- package/lib/commonjs/index.js +59 -0
- package/lib/commonjs/model.js +307 -0
- package/lib/commonjs/pipeline.js +55 -0
- package/lib/commonjs/presets.js +32 -0
- package/lib/commonjs/session.js +76 -0
- package/lib/commonjs/types.js +10 -0
- package/lib/commonjs/wire.js +285 -0
- package/lib/commonjs/xybrid.js +133 -0
- package/lib/module/NativeXybrid.js +22 -0
- package/lib/module/base64.js +41 -0
- package/lib/module/cancel.js +29 -0
- package/lib/module/context.js +60 -0
- package/lib/module/envelope.js +73 -0
- package/lib/module/errors.js +56 -0
- package/lib/module/index.js +36 -0
- package/lib/module/model.js +298 -0
- package/lib/module/pipeline.js +48 -0
- package/lib/module/presets.js +27 -0
- package/lib/module/session.js +69 -0
- package/lib/module/types.js +9 -0
- package/lib/module/wire.js +271 -0
- package/lib/module/xybrid.js +127 -0
- package/lib/typescript/NativeXybrid.d.ts +68 -0
- package/lib/typescript/NativeXybrid.d.ts.map +1 -0
- package/lib/typescript/base64.d.ts +5 -0
- package/lib/typescript/base64.d.ts.map +1 -0
- package/lib/typescript/cancel.d.ts +24 -0
- package/lib/typescript/cancel.d.ts.map +1 -0
- package/lib/typescript/context.d.ts +40 -0
- package/lib/typescript/context.d.ts.map +1 -0
- package/lib/typescript/envelope.d.ts +51 -0
- package/lib/typescript/envelope.d.ts.map +1 -0
- package/lib/typescript/errors.d.ts +12 -0
- package/lib/typescript/errors.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +28 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/model.d.ts +211 -0
- package/lib/typescript/model.d.ts.map +1 -0
- package/lib/typescript/pipeline.d.ts +35 -0
- package/lib/typescript/pipeline.d.ts.map +1 -0
- package/lib/typescript/presets.d.ts +21 -0
- package/lib/typescript/presets.d.ts.map +1 -0
- package/lib/typescript/session.d.ts +50 -0
- package/lib/typescript/session.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +313 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/wire.d.ts +130 -0
- package/lib/typescript/wire.d.ts.map +1 -0
- package/lib/typescript/xybrid.d.ts +89 -0
- package/lib/typescript/xybrid.d.ts.map +1 -0
- package/package.json +88 -0
- package/react-native-xybrid.podspec +50 -0
- package/src/NativeXybrid.ts +131 -0
- package/src/base64.ts +43 -0
- package/src/cancel.ts +47 -0
- package/src/context.ts +70 -0
- package/src/envelope.ts +115 -0
- package/src/errors.ts +67 -0
- package/src/index.ts +91 -0
- package/src/model.ts +408 -0
- package/src/pipeline.ts +66 -0
- package/src/presets.ts +36 -0
- package/src/session.ts +76 -0
- package/src/types.ts +362 -0
- package/src/wire.ts +408 -0
- 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
|
+
}
|