@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,887 @@
1
+ import Foundation
2
+ import React
3
+
4
+ // The Swift half of the TurboModule. XybridModule.mm conforms to the
5
+ // Codegen protocol and forwards every selector here unchanged; each method
6
+ // decodes its arguments (XybridCodec), calls the bolt Swift SDK compiled into
7
+ // this pod (Xybrid.swift + xybrid_bolt.swift), and settles the promise with a
8
+ // typed `xybrid_*` code on failure.
9
+ //
10
+ // Blocking SDK calls (load, run, stream pulls, downloads, disk walks) run on
11
+ // a detached task so the module's method queue stays free; cheap getters
12
+ // settle inline.
13
+
14
+ // File-scope aliases for the generated bolt free functions. Inside the class,
15
+ // a member named like a global (`setBatteryLevel`, `cacheStatus`, …) shadows
16
+ // it — "use of 'X' refers to instance method rather than global function" —
17
+ // and the module name can't qualify it portably. At file scope no member is
18
+ // in scope, so these bind to the globals.
19
+ private let ffiSetBinding = setBinding
20
+ private let ffiInitSdkCacheDir = initSdkCacheDir
21
+ private let ffiConfigureRuntime = configureRuntime
22
+ private let ffiVersion = version
23
+ private let ffiHasApiKey = hasApiKey
24
+ private let ffiSetProviderApiKey = setProviderApiKey
25
+ private let ffiSetPlatformUrl = setPlatformUrl
26
+ private let ffiSetSpeculativeCloud = setSpeculativeCloud
27
+ private let ffiIsSpeculativeCloudEnabled = isSpeculativeCloudEnabled
28
+ private let ffiWillSpeculateForModel = willSpeculateForModel
29
+ private let ffiReleaseMemory = releaseMemory
30
+ private let ffiSetAutoRelease = setAutoRelease
31
+ private let ffiIsAutoReleaseEnabled = isAutoReleaseEnabled
32
+ private let ffiSetBatteryLevel = setBatteryLevel
33
+ private let ffiClearBatteryLevel = clearBatteryLevel
34
+ private let ffiSetThermalState = setThermalState
35
+ private let ffiClearThermalState = clearThermalState
36
+ private let ffiCacheStatus = cacheStatus
37
+ private let ffiCacheEntries = cacheEntries
38
+ private let ffiCacheIsModelCached = cacheIsModelCached
39
+ private let ffiCacheModelPath = cacheModelPath
40
+ private let ffiCacheListExtractedModelIds = cacheListExtractedModelIds
41
+ private let ffiCacheRemoveModel = cacheRemoveModel
42
+ private let ffiCacheClear = cacheClear
43
+ private let ffiJsonSchemaToGbnf = jsonSchemaToGbnf
44
+ private let ffiToolResultsEnvelope = toolResultsEnvelope
45
+
46
+ /// Process-wide SDK setup. The Rust SDK's configuration is process-global
47
+ /// (first-set-wins), and it outlives this module: a JS reload builds a new
48
+ /// module instance but not a new process. So what has been applied is
49
+ /// tracked process-wide too, which is what keeps a reload from starting a
50
+ /// second telemetry exporter.
51
+ private enum SdkSetup {
52
+ struct Runtime: Equatable {
53
+ var apiKey: String?
54
+ var gatewayUrl: String?
55
+ var ingestUrl: String?
56
+
57
+ var isEmpty: Bool { apiKey == nil && gatewayUrl == nil && ingestUrl == nil }
58
+ }
59
+
60
+ private static let lock = NSLock()
61
+ nonisolated(unsafe) private static var appliedCacheDir: String?
62
+ nonisolated(unsafe) private static var appliedRuntime: Runtime?
63
+
64
+ /// Register the binding, battery observers and the cache directory, once.
65
+ /// Every bridged call runs this first, so local inference needs no
66
+ /// `initialize()` at all.
67
+ static func ensureBase(cacheDir requested: String? = nil) throws {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+ if let applied = appliedCacheDir {
71
+ if let requested, requested != applied {
72
+ throw BridgeError.config(
73
+ "the model cache is already at \(applied); cacheDir only applies to the first Xybrid call")
74
+ }
75
+ return
76
+ }
77
+ // First-set-wins in the SDK: claim the binding before Xybrid.initialize()
78
+ // registers "swift". Its own configureRuntime(nil, nil, nil) is a no-op;
79
+ // what it adds is the UIDevice battery observer.
80
+ ffiSetBinding("react-native")
81
+ Xybrid.initialize()
82
+ let directory = try requested ?? defaultCacheDirectory()
83
+ try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
84
+ ffiInitSdkCacheDir(directory)
85
+ appliedCacheDir = directory
86
+ }
87
+
88
+ /// Apply the API key and URL overrides — once per process.
89
+ static func configure(_ runtime: Runtime) throws {
90
+ guard !runtime.isEmpty else { return }
91
+ lock.lock()
92
+ defer { lock.unlock() }
93
+ if let applied = appliedRuntime {
94
+ if applied == runtime { return }
95
+ throw BridgeError.config(
96
+ "Xybrid is already initialized with different options; they apply once per app process")
97
+ }
98
+ ffiConfigureRuntime(runtime.apiKey, runtime.gatewayUrl, runtime.ingestUrl)
99
+ appliedRuntime = runtime
100
+ }
101
+
102
+ private static func defaultCacheDirectory() throws -> String {
103
+ guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
104
+ throw BridgeError.config("could not resolve the caches directory")
105
+ }
106
+ return caches.appendingPathComponent("xybrid/models", isDirectory: true).path
107
+ }
108
+ }
109
+
110
+ /// Clamp a JS millisecond timeout into `UInt64` without trapping on NaN,
111
+ /// infinity, negatives, or values at or above 2^63 (`UInt64.max` itself is
112
+ /// not exactly representable as a `Double`).
113
+ private func clampTimeoutMs(_ raw: Double) -> UInt64 {
114
+ guard raw.isFinite, raw > 0 else { return 0 }
115
+ let ceiling = Double(UInt64(1) << 63)
116
+ return raw >= ceiling ? UInt64(1) << 63 : UInt64(raw)
117
+ }
118
+
119
+ /// Blank means absent for configuration strings.
120
+ private func nonBlank(_ value: String?) -> String? {
121
+ guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
122
+ return nil
123
+ }
124
+ return trimmed
125
+ }
126
+
127
+ @objc(XybridModuleImpl)
128
+ public final class XybridModuleImpl: NSObject {
129
+ private let handles = XybridHandles()
130
+
131
+ // MARK: - Settling promises
132
+
133
+ /// Settle inline — for calls that return immediately.
134
+ private func now(_ resolve: RCTPromiseResolveBlock,
135
+ _ reject: RCTPromiseRejectBlock,
136
+ _ work: () throws -> Any?) {
137
+ do {
138
+ try SdkSetup.ensureBase()
139
+ resolve(try work())
140
+ } catch {
141
+ Self.reject(reject, error)
142
+ }
143
+ }
144
+
145
+ /// Settle from a detached task — for calls that block or suspend.
146
+ private func background(_ resolve: @escaping RCTPromiseResolveBlock,
147
+ _ reject: @escaping RCTPromiseRejectBlock,
148
+ _ work: @escaping () async throws -> Any?) {
149
+ Task.detached {
150
+ do {
151
+ try SdkSetup.ensureBase()
152
+ resolve(try await work())
153
+ } catch {
154
+ Self.reject(reject, error)
155
+ }
156
+ }
157
+ }
158
+
159
+ /// The SDK reports a run stopped by its token as an inference error
160
+ /// ("user_cancelled"); JS gets the dedicated `xybrid_cancelled` code.
161
+ private static func cancellationAware(_ error: Error, _ token: XybridCancellationToken) -> Error {
162
+ guard token.isCancelled(), !(error is BridgeError) else { return error }
163
+ return XybridError.cancelled(message: "The run was cancelled")
164
+ }
165
+
166
+ private static func reject(_ reject: RCTPromiseRejectBlock, _ error: Error) {
167
+ let (code, message) = XybridCodec.rejection(for: error)
168
+ reject(code, message, NSError(domain: "Xybrid", code: 0,
169
+ userInfo: [NSLocalizedDescriptionKey: message]))
170
+ }
171
+
172
+ // MARK: - Lifecycle
173
+
174
+ /// Module teardown (reload, host shutdown): stop in-flight work, free all.
175
+ @objc public func invalidate() {
176
+ handles.all(XybridCancellationToken.self).forEach { $0.cancel() }
177
+ handles.disposeAll()
178
+ }
179
+
180
+ // MARK: - SDK configuration
181
+
182
+ @objc(initialize:resolve:reject:)
183
+ public func initialize(_ options: NSDictionary?,
184
+ resolve: @escaping RCTPromiseResolveBlock,
185
+ reject: @escaping RCTPromiseRejectBlock) {
186
+ do {
187
+ let object = (options as? JSObject) ?? [:]
188
+ let what = "initialize options"
189
+ let cacheDir = try nonBlank(XybridCodec.optionalString(object, "cacheDir", what))
190
+ try SdkSetup.ensureBase(cacheDir: cacheDir.map(XybridCodec.filePath))
191
+ try SdkSetup.configure(SdkSetup.Runtime(
192
+ apiKey: nonBlank(try XybridCodec.optionalString(object, "apiKey", what)),
193
+ gatewayUrl: nonBlank(try XybridCodec.optionalString(object, "gatewayUrl", what)),
194
+ ingestUrl: nonBlank(try XybridCodec.optionalString(object, "ingestUrl", what))
195
+ ))
196
+ resolve(nil)
197
+ } catch {
198
+ Self.reject(reject, error)
199
+ }
200
+ }
201
+
202
+ @objc(sdkVersion:reject:)
203
+ public func sdkVersion(_ resolve: @escaping RCTPromiseResolveBlock,
204
+ reject: @escaping RCTPromiseRejectBlock) {
205
+ now(resolve, reject) { ffiVersion() }
206
+ }
207
+
208
+ @objc(hasApiKey:reject:)
209
+ public func hasApiKey(_ resolve: @escaping RCTPromiseResolveBlock,
210
+ reject: @escaping RCTPromiseRejectBlock) {
211
+ now(resolve, reject) { ffiHasApiKey() }
212
+ }
213
+
214
+ @objc(setProviderApiKey:apiKey:resolve:reject:)
215
+ public func setProviderApiKey(_ provider: String,
216
+ apiKey: String,
217
+ resolve: @escaping RCTPromiseResolveBlock,
218
+ reject: @escaping RCTPromiseRejectBlock) {
219
+ now(resolve, reject) { ffiSetProviderApiKey(provider, apiKey); return nil }
220
+ }
221
+
222
+ @objc(setPlatformUrl:resolve:reject:)
223
+ public func setPlatformUrl(_ url: String,
224
+ resolve: @escaping RCTPromiseResolveBlock,
225
+ reject: @escaping RCTPromiseRejectBlock) {
226
+ now(resolve, reject) { ffiSetPlatformUrl(url); return nil }
227
+ }
228
+
229
+ @objc(setSpeculativeCloud:resolve:reject:)
230
+ public func setSpeculativeCloud(_ enabled: Bool,
231
+ resolve: @escaping RCTPromiseResolveBlock,
232
+ reject: @escaping RCTPromiseRejectBlock) {
233
+ now(resolve, reject) { ffiSetSpeculativeCloud(enabled); return nil }
234
+ }
235
+
236
+ @objc(isSpeculativeCloudEnabled:reject:)
237
+ public func isSpeculativeCloudEnabled(_ resolve: @escaping RCTPromiseResolveBlock,
238
+ reject: @escaping RCTPromiseRejectBlock) {
239
+ now(resolve, reject) { ffiIsSpeculativeCloudEnabled() }
240
+ }
241
+
242
+ @objc(willSpeculate:resolve:reject:)
243
+ public func willSpeculate(_ modelId: String,
244
+ resolve: @escaping RCTPromiseResolveBlock,
245
+ reject: @escaping RCTPromiseRejectBlock) {
246
+ background(resolve, reject) { ffiWillSpeculateForModel(modelId) }
247
+ }
248
+
249
+ @objc(releaseMemory:reject:)
250
+ public func releaseMemory(_ resolve: @escaping RCTPromiseResolveBlock,
251
+ reject: @escaping RCTPromiseRejectBlock) {
252
+ background(resolve, reject) { ffiReleaseMemory() }
253
+ }
254
+
255
+ @objc(setAutoRelease:resolve:reject:)
256
+ public func setAutoRelease(_ enabled: Bool,
257
+ resolve: @escaping RCTPromiseResolveBlock,
258
+ reject: @escaping RCTPromiseRejectBlock) {
259
+ now(resolve, reject) { ffiSetAutoRelease(enabled); return nil }
260
+ }
261
+
262
+ @objc(isAutoReleaseEnabled:reject:)
263
+ public func isAutoReleaseEnabled(_ resolve: @escaping RCTPromiseResolveBlock,
264
+ reject: @escaping RCTPromiseRejectBlock) {
265
+ now(resolve, reject) { ffiIsAutoReleaseEnabled() }
266
+ }
267
+
268
+ // MARK: - Device state push
269
+
270
+ @objc(setBatteryLevel:resolve:reject:)
271
+ public func setBatteryLevel(_ percent: Double,
272
+ resolve: @escaping RCTPromiseResolveBlock,
273
+ reject: @escaping RCTPromiseRejectBlock) {
274
+ now(resolve, reject) {
275
+ guard percent.isFinite else {
276
+ throw BridgeError.invalidArgument("battery level must be a finite number")
277
+ }
278
+ ffiSetBatteryLevel(UInt8(max(0, min(100, percent.rounded()))))
279
+ return nil
280
+ }
281
+ }
282
+
283
+ @objc(clearBatteryLevel:reject:)
284
+ public func clearBatteryLevel(_ resolve: @escaping RCTPromiseResolveBlock,
285
+ reject: @escaping RCTPromiseRejectBlock) {
286
+ now(resolve, reject) { ffiClearBatteryLevel(); return nil }
287
+ }
288
+
289
+ @objc(setThermalState:resolve:reject:)
290
+ public func setThermalState(_ state: String,
291
+ resolve: @escaping RCTPromiseResolveBlock,
292
+ reject: @escaping RCTPromiseRejectBlock) {
293
+ now(resolve, reject) {
294
+ let mapped: XybridThermalState
295
+ switch state {
296
+ case "normal": mapped = .normal
297
+ case "warm": mapped = .warm
298
+ case "hot": mapped = .hot
299
+ case "critical": mapped = .critical
300
+ default: throw BridgeError.invalidArgument("unknown thermal state '\(state)'")
301
+ }
302
+ ffiSetThermalState(mapped)
303
+ return nil
304
+ }
305
+ }
306
+
307
+ @objc(clearThermalState:reject:)
308
+ public func clearThermalState(_ resolve: @escaping RCTPromiseResolveBlock,
309
+ reject: @escaping RCTPromiseRejectBlock) {
310
+ now(resolve, reject) { ffiClearThermalState(); return nil }
311
+ }
312
+
313
+ // MARK: - Model cache (disk walks: off the method queue)
314
+
315
+ @objc(cacheStatus:reject:)
316
+ public func cacheStatus(_ resolve: @escaping RCTPromiseResolveBlock,
317
+ reject: @escaping RCTPromiseRejectBlock) {
318
+ background(resolve, reject) { XybridCodec.encodeCacheStatus(try ffiCacheStatus()) }
319
+ }
320
+
321
+ @objc(cacheEntries:reject:)
322
+ public func cacheEntries(_ resolve: @escaping RCTPromiseResolveBlock,
323
+ reject: @escaping RCTPromiseRejectBlock) {
324
+ background(resolve, reject) { try ffiCacheEntries().map(XybridCodec.encodeCacheEntry) }
325
+ }
326
+
327
+ @objc(cacheIsModelCached:resolve:reject:)
328
+ public func cacheIsModelCached(_ modelId: String,
329
+ resolve: @escaping RCTPromiseResolveBlock,
330
+ reject: @escaping RCTPromiseRejectBlock) {
331
+ background(resolve, reject) { try ffiCacheIsModelCached(modelId) }
332
+ }
333
+
334
+ @objc(cacheModelPath:resolve:reject:)
335
+ public func cacheModelPath(_ modelId: String,
336
+ resolve: @escaping RCTPromiseResolveBlock,
337
+ reject: @escaping RCTPromiseRejectBlock) {
338
+ background(resolve, reject) { try ffiCacheModelPath(modelId) }
339
+ }
340
+
341
+ @objc(cacheExtractedModelIds:reject:)
342
+ public func cacheExtractedModelIds(_ resolve: @escaping RCTPromiseResolveBlock,
343
+ reject: @escaping RCTPromiseRejectBlock) {
344
+ background(resolve, reject) { try ffiCacheListExtractedModelIds() }
345
+ }
346
+
347
+ @objc(cacheRemoveModel:resolve:reject:)
348
+ public func cacheRemoveModel(_ modelId: String,
349
+ resolve: @escaping RCTPromiseResolveBlock,
350
+ reject: @escaping RCTPromiseRejectBlock) {
351
+ background(resolve, reject) { try ffiCacheRemoveModel(modelId) }
352
+ }
353
+
354
+ @objc(cacheClear:reject:)
355
+ public func cacheClear(_ resolve: @escaping RCTPromiseResolveBlock,
356
+ reject: @escaping RCTPromiseRejectBlock) {
357
+ background(resolve, reject) { try ffiCacheClear() }
358
+ }
359
+
360
+ // MARK: - Stateless helpers
361
+
362
+ @objc(jsonSchemaToGbnf:resolve:reject:)
363
+ public func jsonSchemaToGbnf(_ schemaJson: String,
364
+ resolve: @escaping RCTPromiseResolveBlock,
365
+ reject: @escaping RCTPromiseRejectBlock) {
366
+ now(resolve, reject) { try ffiJsonSchemaToGbnf(schemaJson) }
367
+ }
368
+
369
+ @objc(toolResultsEnvelope:priorAssistantText:results:resolve:reject:)
370
+ public func toolResultsEnvelope(_ userText: String,
371
+ priorAssistantText: String,
372
+ results: NSArray,
373
+ resolve: @escaping RCTPromiseResolveBlock,
374
+ reject: @escaping RCTPromiseRejectBlock) {
375
+ now(resolve, reject) {
376
+ let decoded = try (results as? [Any] ?? []).map(XybridCodec.decodeToolResult)
377
+ return XybridCodec.encodeEnvelope(try ffiToolResultsEnvelope(userText, priorAssistantText, decoded))
378
+ }
379
+ }
380
+
381
+ // MARK: - Handles
382
+
383
+ @objc(dispose:resolve:reject:)
384
+ public func dispose(_ handle: String,
385
+ resolve: @escaping RCTPromiseResolveBlock,
386
+ reject: @escaping RCTPromiseRejectBlock) {
387
+ handles.dispose(handle)
388
+ resolve(nil)
389
+ }
390
+
391
+ // MARK: - Models
392
+
393
+ @objc(loadModel:resolve:reject:)
394
+ public func loadModel(_ source: NSDictionary,
395
+ resolve: @escaping RCTPromiseResolveBlock,
396
+ reject: @escaping RCTPromiseRejectBlock) {
397
+ background(resolve, reject) { [handles] in
398
+ let object = try XybridCodec.object(source, "model source")
399
+ let what = "model source"
400
+ let kind = try XybridCodec.string(object, "kind", what)
401
+ let value = try XybridCodec.string(object, "value", what)
402
+ let model: XybridModel
403
+ switch kind {
404
+ case "registry":
405
+ model = try XybridModel(fromRegistry: value)
406
+ case "registrySpeculative":
407
+ model = try XybridModel(fromRegistrySpeculative: value)
408
+ case "bundle":
409
+ model = try XybridModel(fromBundle: XybridCodec.filePath(value))
410
+ case "directory":
411
+ model = try XybridModel(fromDirectory: XybridCodec.filePath(value))
412
+ case "huggingFace":
413
+ if let revision = try XybridCodec.optionalString(object, "revision", what) {
414
+ model = try XybridModel(fromHuggingfaceWithRevision: value, revision: revision)
415
+ } else {
416
+ model = try XybridModel(fromHuggingface: value)
417
+ }
418
+ case "modelFile":
419
+ model = try XybridModel(fromModelFile: XybridCodec.filePath(value))
420
+ default:
421
+ throw BridgeError.invalidArgument("unknown model source kind '\(kind)'")
422
+ }
423
+ return handles.insert(model, kind: "model")
424
+ }
425
+ }
426
+
427
+ @objc(modelInfo:resolve:reject:)
428
+ public func modelInfo(_ model: String,
429
+ resolve: @escaping RCTPromiseResolveBlock,
430
+ reject: @escaping RCTPromiseRejectBlock) {
431
+ now(resolve, reject) {
432
+ let m = try handles.get(model, as: XybridModel.self)
433
+ let info: JSObject = [
434
+ "modelId": m.modelId(),
435
+ "version": m.version(),
436
+ "outputType": XybridCodec.encodeOutputType(m.outputType()),
437
+ "isLlm": m.isLlm(),
438
+ "supportsStreaming": m.supportsStreaming(),
439
+ "supportsTokenStreaming": m.supportsTokenStreaming(),
440
+ "supportsToolCalling": m.supportsToolCalling().map { $0 as Any } ?? NSNull(),
441
+ "hasVoices": m.hasVoices(),
442
+ "defaultGenerationConfig": XybridCodec.encodeGenerationConfig(m.defaultGenerationConfig()),
443
+ ]
444
+ return info
445
+ }
446
+ }
447
+
448
+ @objc(isLoaded:resolve:reject:)
449
+ public func isLoaded(_ model: String,
450
+ resolve: @escaping RCTPromiseResolveBlock,
451
+ reject: @escaping RCTPromiseRejectBlock) {
452
+ now(resolve, reject) { try handles.get(model, as: XybridModel.self).isLoaded() }
453
+ }
454
+
455
+ @objc(warmup:resolve:reject:)
456
+ public func warmup(_ model: String,
457
+ resolve: @escaping RCTPromiseResolveBlock,
458
+ reject: @escaping RCTPromiseRejectBlock) {
459
+ background(resolve, reject) { [handles] in
460
+ try handles.get(model, as: XybridModel.self).warmup()
461
+ return nil
462
+ }
463
+ }
464
+
465
+ @objc(unload:resolve:reject:)
466
+ public func unload(_ model: String,
467
+ resolve: @escaping RCTPromiseResolveBlock,
468
+ reject: @escaping RCTPromiseRejectBlock) {
469
+ background(resolve, reject) { [handles] in
470
+ try handles.get(model, as: XybridModel.self).unload()
471
+ return nil
472
+ }
473
+ }
474
+
475
+ @objc(isCloudServing:resolve:reject:)
476
+ public func isCloudServing(_ model: String,
477
+ resolve: @escaping RCTPromiseResolveBlock,
478
+ reject: @escaping RCTPromiseRejectBlock) {
479
+ now(resolve, reject) { try handles.get(model, as: XybridModel.self).isCloudServing() }
480
+ }
481
+
482
+ @objc(downloadStatus:resolve:reject:)
483
+ public func downloadStatus(_ model: String,
484
+ resolve: @escaping RCTPromiseResolveBlock,
485
+ reject: @escaping RCTPromiseRejectBlock) {
486
+ now(resolve, reject) {
487
+ XybridCodec.encodeDownloadStatus(try handles.get(model, as: XybridModel.self).downloadStatus())
488
+ }
489
+ }
490
+
491
+ @objc(awaitDownload:timeoutMs:resolve:reject:)
492
+ public func awaitDownload(_ model: String,
493
+ timeoutMs: Double,
494
+ resolve: @escaping RCTPromiseResolveBlock,
495
+ reject: @escaping RCTPromiseRejectBlock) {
496
+ background(resolve, reject) { [handles] in
497
+ let m = try handles.get(model, as: XybridModel.self)
498
+ return XybridCodec.encodeDownloadStatus(m.awaitDownload(timeoutMs: clampTimeoutMs(timeoutMs)))
499
+ }
500
+ }
501
+
502
+ @objc(voices:resolve:reject:)
503
+ public func voices(_ model: String,
504
+ resolve: @escaping RCTPromiseResolveBlock,
505
+ reject: @escaping RCTPromiseRejectBlock) {
506
+ now(resolve, reject) {
507
+ try handles.get(model, as: XybridModel.self).voices().map(XybridCodec.encodeVoice)
508
+ }
509
+ }
510
+
511
+ @objc(defaultVoice:resolve:reject:)
512
+ public func defaultVoice(_ model: String,
513
+ resolve: @escaping RCTPromiseResolveBlock,
514
+ reject: @escaping RCTPromiseRejectBlock) {
515
+ now(resolve, reject) {
516
+ try handles.get(model, as: XybridModel.self).defaultVoice().map(XybridCodec.encodeVoice)
517
+ }
518
+ }
519
+
520
+ @objc(voice:voiceId:resolve:reject:)
521
+ public func voice(_ model: String,
522
+ voiceId: String,
523
+ resolve: @escaping RCTPromiseResolveBlock,
524
+ reject: @escaping RCTPromiseRejectBlock) {
525
+ now(resolve, reject) {
526
+ try handles.get(model, as: XybridModel.self).voice(voiceId: voiceId).map(XybridCodec.encodeVoice)
527
+ }
528
+ }
529
+
530
+ // MARK: - Inference
531
+
532
+ /// Resolve the handles a run request carries. Without a caller token a
533
+ /// batch run still gets one, registered under the model so disposing the
534
+ /// model (or tearing the module down) stops it; a stream's own entry plays
535
+ /// that role instead, so streams skip the registration.
536
+ private func prepareRun(_ modelHandle: String, _ options: NSDictionary?, registerToken: Bool) throws
537
+ -> (model: XybridModel, request: XybridCodec.RunRequest,
538
+ context: XybridConversationContext?, cancel: XybridCancellationToken, ownToken: String?)
539
+ {
540
+ let model = try handles.get(modelHandle, as: XybridModel.self)
541
+ let request = try XybridCodec.decodeRunRequest(options)
542
+ let context = try request.context.map { try handles.get($0, as: XybridConversationContext.self) }
543
+ if let token = request.cancel {
544
+ return (model, request, context, try handles.get(token, as: XybridCancellationToken.self), nil)
545
+ }
546
+ let token = XybridCancellationToken()
547
+ let handle = registerToken
548
+ ? handles.insert(token, kind: "cancel", owner: modelHandle, onDispose: { token.cancel() })
549
+ : nil
550
+ return (model, request, context, token, handle)
551
+ }
552
+
553
+ @objc(run:envelope:options:resolve:reject:)
554
+ public func run(_ model: String,
555
+ envelope: NSDictionary,
556
+ options: NSDictionary?,
557
+ resolve: @escaping RCTPromiseResolveBlock,
558
+ reject: @escaping RCTPromiseRejectBlock) {
559
+ background(resolve, reject) { [handles] in
560
+ let input = try XybridCodec.decodeEnvelope(envelope)
561
+ let run = try self.prepareRun(model, options, registerToken: true)
562
+ defer { if let own = run.ownToken { handles.dispose(own) } }
563
+ do {
564
+ let result: XybridResult
565
+ if let context = run.context {
566
+ result = try run.model.runWithContext(envelope: input, context: context,
567
+ options: run.request.options, cancel: run.cancel)
568
+ } else {
569
+ result = try run.model.run(envelope: input, options: run.request.options, cancel: run.cancel)
570
+ }
571
+ return XybridCodec.encodeResult(result)
572
+ } catch {
573
+ throw Self.cancellationAware(error, run.cancel)
574
+ }
575
+ }
576
+ }
577
+
578
+ @objc(streamStart:envelope:options:resolve:reject:)
579
+ public func streamStart(_ model: String,
580
+ envelope: NSDictionary,
581
+ options: NSDictionary?,
582
+ resolve: @escaping RCTPromiseResolveBlock,
583
+ reject: @escaping RCTPromiseRejectBlock) {
584
+ background(resolve, reject) { [handles] in
585
+ let input = try XybridCodec.decodeEnvelope(envelope)
586
+ let run = try self.prepareRun(model, options, registerToken: false)
587
+ let streamId: UInt64
588
+ if let context = run.context {
589
+ streamId = try run.model.runStreamWithContext(envelope: input, context: context,
590
+ options: run.request.options, cancel: run.cancel)
591
+ } else {
592
+ streamId = try run.model.runStream(envelope: input, options: run.request.options, cancel: run.cancel)
593
+ }
594
+ let entry = XybridTokenStreamEntry(model: run.model, streamId: streamId, cancel: run.cancel)
595
+ return handles.insert(entry, kind: "stream", owner: model, onDispose: { entry.abort() })
596
+ }
597
+ }
598
+
599
+ @objc(streamNext:resolve:reject:)
600
+ public func streamNext(_ stream: String,
601
+ resolve: @escaping RCTPromiseResolveBlock,
602
+ reject: @escaping RCTPromiseRejectBlock) {
603
+ background(resolve, reject) { [handles] in
604
+ // A disposed stream reads as exhausted, not as an error.
605
+ guard let entry = handles.find(stream, as: XybridTokenStreamEntry.self) else { return nil }
606
+ do {
607
+ let event = try entry.model.streamNext(streamId: entry.streamId)
608
+ switch event.kind {
609
+ case .token:
610
+ guard let token = event.token else {
611
+ throw XybridError.inferenceError(message: "stream returned a token event without a token")
612
+ }
613
+ return ["kind": "token", "token": XybridCodec.encodeStreamToken(token)] as JSObject
614
+ case .complete:
615
+ // `streamResult` closes the bolt session; drop our entry with it.
616
+ let result = try entry.model.streamResult(streamId: entry.streamId)
617
+ handles.dispose(stream)
618
+ return ["kind": "complete", "result": XybridCodec.encodeResult(result)] as JSObject
619
+ }
620
+ } catch {
621
+ // A failed pull already closed the bolt session.
622
+ handles.dispose(stream)
623
+ throw Self.cancellationAware(error, entry.cancel)
624
+ }
625
+ }
626
+ }
627
+
628
+ // MARK: - Cancellation tokens
629
+
630
+ @objc(createCancelToken:reject:)
631
+ public func createCancelToken(_ resolve: @escaping RCTPromiseResolveBlock,
632
+ reject: @escaping RCTPromiseRejectBlock) {
633
+ now(resolve, reject) { handles.insert(XybridCancellationToken(), kind: "cancel") }
634
+ }
635
+
636
+ @objc(cancel:resolve:reject:)
637
+ public func cancel(_ token: String,
638
+ resolve: @escaping RCTPromiseResolveBlock,
639
+ reject: @escaping RCTPromiseRejectBlock) {
640
+ now(resolve, reject) {
641
+ try handles.get(token, as: XybridCancellationToken.self).cancel()
642
+ return nil
643
+ }
644
+ }
645
+
646
+ // MARK: - Conversation contexts
647
+
648
+ @objc(createContext:resolve:reject:)
649
+ public func createContext(_ contextId: String?,
650
+ resolve: @escaping RCTPromiseResolveBlock,
651
+ reject: @escaping RCTPromiseRejectBlock) {
652
+ now(resolve, reject) {
653
+ let context = contextId.map { XybridConversationContext(withId: $0) } ?? XybridConversationContext()
654
+ return handles.insert(context, kind: "context")
655
+ }
656
+ }
657
+
658
+ @objc(contextPush:envelope:resolve:reject:)
659
+ public func contextPush(_ context: String,
660
+ envelope: NSDictionary,
661
+ resolve: @escaping RCTPromiseResolveBlock,
662
+ reject: @escaping RCTPromiseRejectBlock) {
663
+ // Image turns are decode-validated natively, so this can take a moment.
664
+ background(resolve, reject) { [handles] in
665
+ try handles.get(context, as: XybridConversationContext.self)
666
+ .push(envelope: try XybridCodec.decodeEnvelope(envelope))
667
+ return nil
668
+ }
669
+ }
670
+
671
+ @objc(contextSetSystem:envelope:resolve:reject:)
672
+ public func contextSetSystem(_ context: String,
673
+ envelope: NSDictionary,
674
+ resolve: @escaping RCTPromiseResolveBlock,
675
+ reject: @escaping RCTPromiseRejectBlock) {
676
+ background(resolve, reject) { [handles] in
677
+ try handles.get(context, as: XybridConversationContext.self)
678
+ .setSystem(envelope: try XybridCodec.decodeEnvelope(envelope))
679
+ return nil
680
+ }
681
+ }
682
+
683
+ @objc(contextClear:resolve:reject:)
684
+ public func contextClear(_ context: String,
685
+ resolve: @escaping RCTPromiseResolveBlock,
686
+ reject: @escaping RCTPromiseRejectBlock) {
687
+ now(resolve, reject) {
688
+ try handles.get(context, as: XybridConversationContext.self).clear()
689
+ return nil
690
+ }
691
+ }
692
+
693
+ @objc(contextInfo:resolve:reject:)
694
+ public func contextInfo(_ context: String,
695
+ resolve: @escaping RCTPromiseResolveBlock,
696
+ reject: @escaping RCTPromiseRejectBlock) {
697
+ now(resolve, reject) {
698
+ let c = try handles.get(context, as: XybridConversationContext.self)
699
+ return ["id": c.id(), "historyLength": c.historyLen(), "hasSystem": c.hasSystem()] as JSObject
700
+ }
701
+ }
702
+
703
+ @objc(contextHistory:resolve:reject:)
704
+ public func contextHistory(_ context: String,
705
+ resolve: @escaping RCTPromiseResolveBlock,
706
+ reject: @escaping RCTPromiseRejectBlock) {
707
+ now(resolve, reject) {
708
+ try handles.get(context, as: XybridConversationContext.self).history().map(XybridCodec.encodeEnvelope)
709
+ }
710
+ }
711
+
712
+ @objc(contextSetMaxHistoryLength:length:resolve:reject:)
713
+ public func contextSetMaxHistoryLength(_ context: String,
714
+ length: Double,
715
+ resolve: @escaping RCTPromiseResolveBlock,
716
+ reject: @escaping RCTPromiseRejectBlock) {
717
+ now(resolve, reject) {
718
+ let c = try handles.get(context, as: XybridConversationContext.self)
719
+ c.setMaxHistoryLen(len: try XybridCodec.uint32(length, "max history length"))
720
+ return nil
721
+ }
722
+ }
723
+
724
+ // MARK: - Standalone downloads
725
+
726
+ @objc(startDownload:platform:resolve:reject:)
727
+ public func startDownload(_ modelId: String,
728
+ platform: String?,
729
+ resolve: @escaping RCTPromiseResolveBlock,
730
+ reject: @escaping RCTPromiseRejectBlock) {
731
+ background(resolve, reject) { [handles] in
732
+ let download = platform.map { XybridDownload(fromRegistryWithPlatform: modelId, platform: $0) }
733
+ ?? XybridDownload(fromRegistry: modelId)
734
+ return handles.insert(download, kind: "download")
735
+ }
736
+ }
737
+
738
+ @objc(downloadHandleStatus:resolve:reject:)
739
+ public func downloadHandleStatus(_ download: String,
740
+ resolve: @escaping RCTPromiseResolveBlock,
741
+ reject: @escaping RCTPromiseRejectBlock) {
742
+ now(resolve, reject) {
743
+ XybridCodec.encodeDownloadStatus(try handles.get(download, as: XybridDownload.self).status())
744
+ }
745
+ }
746
+
747
+ @objc(downloadHandleError:resolve:reject:)
748
+ public func downloadHandleError(_ download: String,
749
+ resolve: @escaping RCTPromiseResolveBlock,
750
+ reject: @escaping RCTPromiseRejectBlock) {
751
+ now(resolve, reject) { try handles.get(download, as: XybridDownload.self).error() }
752
+ }
753
+
754
+ @objc(cancelDownload:resolve:reject:)
755
+ public func cancelDownload(_ download: String,
756
+ resolve: @escaping RCTPromiseResolveBlock,
757
+ reject: @escaping RCTPromiseRejectBlock) {
758
+ now(resolve, reject) {
759
+ try handles.get(download, as: XybridDownload.self).cancel()
760
+ return nil
761
+ }
762
+ }
763
+
764
+ // MARK: - Pipelines
765
+
766
+ @objc(loadPipeline:resolve:reject:)
767
+ public func loadPipeline(_ source: NSDictionary,
768
+ resolve: @escaping RCTPromiseResolveBlock,
769
+ reject: @escaping RCTPromiseRejectBlock) {
770
+ background(resolve, reject) { [handles] in
771
+ let object = try XybridCodec.object(source, "pipeline source")
772
+ let kind = try XybridCodec.string(object, "kind", "pipeline source")
773
+ let value = try XybridCodec.string(object, "value", "pipeline source")
774
+ let pipeline: XybridPipeline
775
+ switch kind {
776
+ case "yaml": pipeline = try XybridPipeline(fromYaml: value)
777
+ case "file": pipeline = try XybridPipeline(fromFile: XybridCodec.filePath(value))
778
+ case "bundle": pipeline = try XybridPipeline(fromBundle: XybridCodec.filePath(value))
779
+ default: throw BridgeError.invalidArgument("unknown pipeline source kind '\(kind)'")
780
+ }
781
+ return handles.insert(pipeline, kind: "pipeline")
782
+ }
783
+ }
784
+
785
+ @objc(pipelineInfo:resolve:reject:)
786
+ public func pipelineInfo(_ pipeline: String,
787
+ resolve: @escaping RCTPromiseResolveBlock,
788
+ reject: @escaping RCTPromiseRejectBlock) {
789
+ now(resolve, reject) {
790
+ let p = try handles.get(pipeline, as: XybridPipeline.self)
791
+ return [
792
+ "name": p.name().map { $0 as Any } ?? NSNull(),
793
+ "stageNames": p.stageNames(),
794
+ "stageCount": p.stageCount(),
795
+ ] as JSObject
796
+ }
797
+ }
798
+
799
+ @objc(runPipeline:envelope:options:resolve:reject:)
800
+ public func runPipeline(_ pipeline: String,
801
+ envelope: NSDictionary,
802
+ options: NSDictionary?,
803
+ resolve: @escaping RCTPromiseResolveBlock,
804
+ reject: @escaping RCTPromiseRejectBlock) {
805
+ background(resolve, reject) { [handles] in
806
+ let p = try handles.get(pipeline, as: XybridPipeline.self)
807
+ let result = try p.run(envelope: try XybridCodec.decodeEnvelope(envelope),
808
+ options: try XybridCodec.decodeRunRequest(options).options)
809
+ return XybridCodec.encodePipelineResult(result)
810
+ }
811
+ }
812
+
813
+ // MARK: - Live ASR sessions
814
+
815
+ @objc(openStreamingSession:config:resolve:reject:)
816
+ public func openStreamingSession(_ model: String,
817
+ config: NSDictionary?,
818
+ resolve: @escaping RCTPromiseResolveBlock,
819
+ reject: @escaping RCTPromiseRejectBlock) {
820
+ // Opening warms the weights, so it runs off the method queue.
821
+ background(resolve, reject) { [handles] in
822
+ let m = try handles.get(model, as: XybridModel.self)
823
+ let session = try XybridStreamingSession(forModel: m, config: try XybridCodec.decodeStreamingConfig(config))
824
+ let entry = XybridSessionEntry(session: session)
825
+ return handles.insert(entry, kind: "session", onDispose: { session.cancel() })
826
+ }
827
+ }
828
+
829
+ @objc(sessionFeed:samplesBase64:resolve:reject:)
830
+ public func sessionFeed(_ session: String,
831
+ samplesBase64: String,
832
+ resolve: @escaping RCTPromiseResolveBlock,
833
+ reject: @escaping RCTPromiseRejectBlock) {
834
+ // `feed` blocks only when the worker's queue is full (back-pressure).
835
+ background(resolve, reject) { [handles] in
836
+ let entry = try handles.get(session, as: XybridSessionEntry.self)
837
+ try entry.session.feed(samples: try XybridCodec.float32Samples(samplesBase64))
838
+ return nil
839
+ }
840
+ }
841
+
842
+ @objc(sessionNextPartial:resolve:reject:)
843
+ public func sessionNextPartial(_ session: String,
844
+ resolve: @escaping RCTPromiseResolveBlock,
845
+ reject: @escaping RCTPromiseRejectBlock) {
846
+ background(resolve, reject) { [handles] in
847
+ guard let entry = handles.find(session, as: XybridSessionEntry.self) else { return nil }
848
+ return await entry.nextPartial().map(XybridCodec.encodePartial)
849
+ }
850
+ }
851
+
852
+ @objc(sessionFlush:resolve:reject:)
853
+ public func sessionFlush(_ session: String,
854
+ resolve: @escaping RCTPromiseResolveBlock,
855
+ reject: @escaping RCTPromiseRejectBlock) {
856
+ background(resolve, reject) { [handles] in
857
+ try handles.get(session, as: XybridSessionEntry.self).session.flush()
858
+ }
859
+ }
860
+
861
+ @objc(sessionReset:resolve:reject:)
862
+ public func sessionReset(_ session: String,
863
+ resolve: @escaping RCTPromiseResolveBlock,
864
+ reject: @escaping RCTPromiseRejectBlock) {
865
+ background(resolve, reject) { [handles] in
866
+ try handles.get(session, as: XybridSessionEntry.self).session.reset()
867
+ return nil
868
+ }
869
+ }
870
+
871
+ @objc(sessionCancel:resolve:reject:)
872
+ public func sessionCancel(_ session: String,
873
+ resolve: @escaping RCTPromiseResolveBlock,
874
+ reject: @escaping RCTPromiseRejectBlock) {
875
+ now(resolve, reject) {
876
+ try handles.get(session, as: XybridSessionEntry.self).session.cancel()
877
+ return nil
878
+ }
879
+ }
880
+
881
+ @objc(sessionIsRunning:resolve:reject:)
882
+ public func sessionIsRunning(_ session: String,
883
+ resolve: @escaping RCTPromiseResolveBlock,
884
+ reject: @escaping RCTPromiseRejectBlock) {
885
+ now(resolve, reject) { try handles.get(session, as: XybridSessionEntry.self).session.isRunning() }
886
+ }
887
+ }