@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,481 @@
1
+ package ai.xybrid.reactnative
2
+
3
+ import ai.xybrid.XybridAbortSignal
4
+ import ai.xybrid.XybridCacheEntry
5
+ import ai.xybrid.XybridCacheEntryLocation
6
+ import ai.xybrid.XybridCacheStatus
7
+ import ai.xybrid.XybridDownloadState
8
+ import ai.xybrid.XybridDownloadStatus
9
+ import ai.xybrid.XybridEnvelope
10
+ import ai.xybrid.XybridEnvelopeKind
11
+ import ai.xybrid.XybridError
12
+ import ai.xybrid.XybridExecutionTarget
13
+ import ai.xybrid.XybridGenerationConfig
14
+ import ai.xybrid.XybridInferenceMetrics
15
+ import ai.xybrid.XybridMetadataEntry
16
+ import ai.xybrid.XybridOutputType
17
+ import ai.xybrid.XybridPartialResult
18
+ import ai.xybrid.XybridPipelineResult
19
+ import ai.xybrid.XybridResult
20
+ import ai.xybrid.XybridRunOptions
21
+ import ai.xybrid.XybridStageResult
22
+ import ai.xybrid.XybridStreamToken
23
+ import ai.xybrid.XybridStreamingConfig
24
+ import ai.xybrid.XybridToolCall
25
+ import ai.xybrid.XybridToolDefinition
26
+ import ai.xybrid.XybridToolResult
27
+ import ai.xybrid.XybridVadMode
28
+ import ai.xybrid.XybridVoiceInfo
29
+ import android.util.Base64
30
+ import java.nio.ByteBuffer
31
+ import java.nio.ByteOrder
32
+
33
+ // Translation between the bolt Kotlin types and plain Kotlin maps and lists.
34
+ //
35
+ // Pure functions only — no SDK calls, no React Native types — so
36
+ // tests/kotlin runs them on a plain JVM against the real bolt records.
37
+ // XybridModule converts at the edge: `ReadableMap.toHashMap()` in,
38
+ // `Arguments.makeNativeMap()` out. The wire shapes are defined once, in
39
+ // src/wire.ts; envelopes are a payload kind plus string metadata, exactly
40
+ // like `XybridEnvelope`, because every convenience field was already folded
41
+ // into metadata on the JS side.
42
+ //
43
+ // Every number crosses as a Double (JS numbers), and unsigned values are
44
+ // converted explicitly: `Arguments` cannot marshal Kotlin's UInt/ULong.
45
+
46
+ /** A JS object as `ReadableMap.toHashMap()` hands it over. */
47
+ internal typealias JsObject = Map<String, Any?>
48
+
49
+ /** A failure raised by the bridge itself rather than the SDK. */
50
+ internal sealed class BridgeException(message: String) : RuntimeException(message) {
51
+ /** A JS argument could not be decoded. → `xybrid_invalid_argument` */
52
+ class InvalidArgument(message: String) : BridgeException(message)
53
+
54
+ /** Unknown, released, or wrong-kind handle. → `xybrid_handle` */
55
+ class Handle(message: String) : BridgeException(message)
56
+
57
+ /** A configuration conflict. → `xybrid_config_error` */
58
+ class Config(message: String) : BridgeException(message)
59
+ }
60
+
61
+ internal object XybridCodec {
62
+ // -- Reading JS values --------------------------------------------------------
63
+
64
+ fun obj(any: Any?, what: String): JsObject {
65
+ @Suppress("UNCHECKED_CAST")
66
+ return any as? Map<String, Any?> ?: throw BridgeException.InvalidArgument("$what must be an object")
67
+ }
68
+
69
+ fun optionalObj(o: JsObject, key: String, what: String): JsObject? = o[key]?.let { obj(it, "$what: '$key'") }
70
+
71
+ fun string(o: JsObject, key: String, what: String): String =
72
+ o[key] as? String ?: throw BridgeException.InvalidArgument("$what: '$key' must be a string")
73
+
74
+ fun optionalString(o: JsObject, key: String, what: String): String? {
75
+ val raw = o[key] ?: return null
76
+ return raw as? String ?: throw BridgeException.InvalidArgument("$what: '$key' must be a string")
77
+ }
78
+
79
+ fun optionalBoolean(o: JsObject, key: String, what: String): Boolean? {
80
+ val raw = o[key] ?: return null
81
+ return raw as? Boolean ?: throw BridgeException.InvalidArgument("$what: '$key' must be a boolean")
82
+ }
83
+
84
+ fun optionalDouble(o: JsObject, key: String, what: String): Double? {
85
+ val raw = o[key] ?: return null
86
+ val number = (raw as? Number)?.toDouble()
87
+ if (number == null || !number.isFinite()) {
88
+ throw BridgeException.InvalidArgument("$what: '$key' must be a finite number")
89
+ }
90
+ return number
91
+ }
92
+
93
+ fun optionalFloat(o: JsObject, key: String, what: String): Float? = optionalDouble(o, key, what)?.toFloat()
94
+
95
+ /** A non-negative integer that fits in `UInt`; fractions truncate. */
96
+ fun optionalUInt(o: JsObject, key: String, what: String): UInt? =
97
+ optionalDouble(o, key, what)?.let { uint(it, "$what: '$key'") }
98
+
99
+ fun uint(value: Double, what: String): UInt {
100
+ if (!value.isFinite() || value < 0 || value > UInt.MAX_VALUE.toDouble()) {
101
+ throw BridgeException.InvalidArgument("$what must be between 0 and ${UInt.MAX_VALUE}")
102
+ }
103
+ return value.toLong().toUInt()
104
+ }
105
+
106
+ fun stringList(o: JsObject, key: String, what: String): List<String> {
107
+ val raw = o[key] ?: return emptyList()
108
+ val list = raw as? List<*> ?: throw BridgeException.InvalidArgument("$what: '$key' must be an array of strings")
109
+ return list.map { it as? String ?: throw BridgeException.InvalidArgument("$what: '$key' must be an array of strings") }
110
+ }
111
+
112
+ fun base64(o: JsObject, key: String, what: String): ByteArray =
113
+ decodeBase64(string(o, key, what)) ?: throw BridgeException.InvalidArgument("$what: '$key' is not valid base64")
114
+
115
+ private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$")
116
+
117
+ /**
118
+ * Strict standard base64 (whitespace ignored), or `null`. Android's own
119
+ * decoder silently skips characters outside the alphabet, which would turn
120
+ * garbage into an empty payload instead of an error — and disagree with iOS.
121
+ */
122
+ fun decodeBase64(encoded: String): ByteArray? {
123
+ val compact = encoded.filterNot(Char::isWhitespace)
124
+ if (compact.length % 4 != 0 || !base64Shape.matches(compact)) return null
125
+ return try {
126
+ Base64.decode(compact, Base64.NO_WRAP)
127
+ } catch (e: IllegalArgumentException) {
128
+ null
129
+ }
130
+ }
131
+
132
+ /** Accept plain paths and `file://` URLs (what Expo's file APIs hand out). */
133
+ fun filePath(raw: String): String =
134
+ if (raw.startsWith("file://")) java.net.URI(raw).path ?: raw else raw
135
+
136
+ /** Little-endian Float32 PCM from base64. */
137
+ fun float32Samples(encoded: String): FloatArray {
138
+ val bytes = decodeBase64(encoded) ?: throw BridgeException.InvalidArgument("samples are not valid base64")
139
+ if (bytes.size % 4 != 0) {
140
+ throw BridgeException.InvalidArgument("samples must be whole Float32 values (${bytes.size} bytes)")
141
+ }
142
+ val floats = FloatArray(bytes.size / 4)
143
+ ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(floats)
144
+ return floats
145
+ }
146
+
147
+ // -- Envelopes ------------------------------------------------------------------
148
+
149
+ fun decodeEnvelope(any: Any?): XybridEnvelope {
150
+ val o = obj(any, "envelope")
151
+ val kind = string(o, "kind", "envelope")
152
+ val what = "$kind envelope"
153
+ val payload = when (kind) {
154
+ "text" -> XybridEnvelopeKind.Text(string(o, "text", what))
155
+ "audio" -> XybridEnvelopeKind.Audio(base64(o, "bytesBase64", what))
156
+ "embedding" -> {
157
+ val numbers = o["data"] as? List<*>
158
+ ?: throw BridgeException.InvalidArgument("$what: 'data' must be an array of numbers")
159
+ XybridEnvelopeKind.Embedding(
160
+ FloatArray(numbers.size) { i ->
161
+ (numbers[i] as? Number)?.toFloat()
162
+ ?: throw BridgeException.InvalidArgument("$what: 'data' must be an array of numbers")
163
+ },
164
+ )
165
+ }
166
+ "image" -> XybridEnvelopeKind.Image(base64(o, "bytesBase64", what), string(o, "format", what))
167
+ "multipart" -> {
168
+ val parts = o["parts"] as? List<*>
169
+ ?: throw BridgeException.InvalidArgument("$what: 'parts' must be an array")
170
+ XybridEnvelopeKind.MultiPart(parts.map(::decodeEnvelope))
171
+ }
172
+ else -> throw BridgeException.InvalidArgument("unknown envelope kind '$kind'")
173
+ }
174
+ return XybridEnvelope(payload, decodeMetadata(o["metadata"]))
175
+ }
176
+
177
+ fun decodeMetadata(any: Any?): List<XybridMetadataEntry> {
178
+ if (any == null) return emptyList()
179
+ val o = any as? Map<*, *>
180
+ ?: throw BridgeException.InvalidArgument("envelope metadata must be an object of strings")
181
+ return o.keys.map { it.toString() }.sorted().map { key ->
182
+ val value = o[key] as? String
183
+ ?: throw BridgeException.InvalidArgument("envelope metadata '$key' must be a string")
184
+ XybridMetadataEntry(key, value)
185
+ }
186
+ }
187
+
188
+ fun encodeEnvelope(envelope: XybridEnvelope): Map<String, Any?> {
189
+ val out = linkedMapOf<String, Any?>()
190
+ when (val kind = envelope.kind) {
191
+ is XybridEnvelopeKind.Text -> {
192
+ out["kind"] = "text"
193
+ out["text"] = kind.text
194
+ }
195
+ is XybridEnvelopeKind.Audio -> {
196
+ out["kind"] = "audio"
197
+ out["bytesBase64"] = Base64.encodeToString(kind.bytes, Base64.NO_WRAP)
198
+ }
199
+ is XybridEnvelopeKind.Embedding -> {
200
+ out["kind"] = "embedding"
201
+ out["data"] = kind.values.map { it.toDouble() }
202
+ }
203
+ is XybridEnvelopeKind.Image -> {
204
+ out["kind"] = "image"
205
+ out["bytesBase64"] = Base64.encodeToString(kind.bytes, Base64.NO_WRAP)
206
+ out["format"] = kind.format
207
+ }
208
+ is XybridEnvelopeKind.MultiPart -> {
209
+ out["kind"] = "multipart"
210
+ out["parts"] = kind.parts.map(::encodeEnvelope)
211
+ }
212
+ }
213
+ out["metadata"] = envelope.metadata.associate { it.key to it.value }
214
+ return out
215
+ }
216
+
217
+ // -- Generation and run options -----------------------------------------------
218
+
219
+ fun decodeToolDefinition(any: Any?): XybridToolDefinition {
220
+ val o = obj(any, "tool")
221
+ return XybridToolDefinition(
222
+ name = string(o, "name", "tool"),
223
+ description = string(o, "description", "tool"),
224
+ parametersJson = string(o, "parametersJson", "tool"),
225
+ )
226
+ }
227
+
228
+ fun decodeGenerationConfig(o: JsObject): XybridGenerationConfig {
229
+ val what = "generationConfig"
230
+ val tools = (o["tools"] as? List<*>).orEmpty().map(::decodeToolDefinition)
231
+ return XybridGenerationConfig(
232
+ maxTokens = optionalUInt(o, "maxTokens", what),
233
+ temperature = optionalFloat(o, "temperature", what),
234
+ topP = optionalFloat(o, "topP", what),
235
+ minP = optionalFloat(o, "minP", what),
236
+ topK = optionalUInt(o, "topK", what),
237
+ repetitionPenalty = optionalFloat(o, "repetitionPenalty", what),
238
+ stopSequences = stringList(o, "stopSequences", what),
239
+ grammar = optionalString(o, "grammar", what),
240
+ tools = tools,
241
+ )
242
+ }
243
+
244
+ fun encodeGenerationConfig(config: XybridGenerationConfig): Map<String, Any?> {
245
+ val out = linkedMapOf<String, Any?>(
246
+ "stopSequences" to config.stopSequences,
247
+ "tools" to config.tools.map {
248
+ mapOf("name" to it.name, "description" to it.description, "parametersJson" to it.parametersJson)
249
+ },
250
+ )
251
+ config.maxTokens?.let { out["maxTokens"] = it.toDouble() }
252
+ config.temperature?.let { out["temperature"] = it.toDouble() }
253
+ config.topP?.let { out["topP"] = it.toDouble() }
254
+ config.minP?.let { out["minP"] = it.toDouble() }
255
+ config.topK?.let { out["topK"] = it.toDouble() }
256
+ config.repetitionPenalty?.let { out["repetitionPenalty"] = it.toDouble() }
257
+ config.grammar?.let { out["grammar"] = it }
258
+ return out
259
+ }
260
+
261
+ fun decodeAbortSignal(raw: String): XybridAbortSignal = when (raw) {
262
+ "memoryPressureWarn" -> XybridAbortSignal.MEMORY_PRESSURE_WARN
263
+ "memoryPressureCritical" -> XybridAbortSignal.MEMORY_PRESSURE_CRITICAL
264
+ "thermalHot" -> XybridAbortSignal.THERMAL_HOT
265
+ "thermalCritical" -> XybridAbortSignal.THERMAL_CRITICAL
266
+ else -> throw BridgeException.InvalidArgument("unknown abort signal '$raw'")
267
+ }
268
+
269
+ /** Run options plus the two handles that ride along with them. */
270
+ data class RunRequest(
271
+ val options: XybridRunOptions? = null,
272
+ val context: String? = null,
273
+ val cancel: String? = null,
274
+ )
275
+
276
+ fun decodeRunRequest(any: Any?): RunRequest {
277
+ if (any == null) return RunRequest()
278
+ val what = "run options"
279
+ val o = obj(any, what)
280
+ val options = XybridRunOptions(
281
+ generationConfig = optionalObj(o, "generationConfig", what)?.let(::decodeGenerationConfig),
282
+ abortOn = stringList(o, "abortOn", what).map(::decodeAbortSignal),
283
+ fallbackToCloud = optionalBoolean(o, "fallbackToCloud", what) ?: false,
284
+ maxGraceTokens = optionalUInt(o, "maxGraceTokens", what) ?: 0u,
285
+ correlationId = optionalString(o, "correlationId", what),
286
+ )
287
+ return RunRequest(
288
+ options = options,
289
+ context = optionalString(o, "context", what),
290
+ cancel = optionalString(o, "cancel", what),
291
+ )
292
+ }
293
+
294
+ fun decodeToolResult(any: Any?): XybridToolResult {
295
+ val o = obj(any, "tool result")
296
+ return XybridToolResult(
297
+ callId = string(o, "callId", "tool result"),
298
+ name = string(o, "name", "tool result"),
299
+ contentJson = string(o, "contentJson", "tool result"),
300
+ )
301
+ }
302
+
303
+ // -- Results --------------------------------------------------------------------
304
+
305
+ fun encodeOutputType(type: XybridOutputType): String = when (type) {
306
+ XybridOutputType.TEXT -> "text"
307
+ XybridOutputType.AUDIO -> "audio"
308
+ XybridOutputType.EMBEDDING -> "embedding"
309
+ XybridOutputType.UNKNOWN -> "unknown"
310
+ }
311
+
312
+ fun encodeExecutionTarget(target: XybridExecutionTarget): String =
313
+ if (target == XybridExecutionTarget.CLOUD) "cloud" else "local"
314
+
315
+ /** Metrics in native stage order; LLM-only values only when reported. */
316
+ fun encodeMetrics(metrics: XybridInferenceMetrics): Map<String, Any?> {
317
+ val out = linkedMapOf<String, Any?>("totalMs" to metrics.totalMs.toDouble())
318
+ metrics.ttftMs?.let { out["ttftMs"] = it.toDouble() }
319
+ metrics.tokensPerSecond?.let { out["tokensPerSecond"] = it.toDouble() }
320
+ metrics.prefillTps?.let { out["prefillTps"] = it.toDouble() }
321
+ metrics.decodeTps?.let { out["decodeTps"] = it.toDouble() }
322
+ metrics.tokensOut?.let { out["tokensOut"] = it.toDouble() }
323
+ out["stageLatenciesMs"] = metrics.stageLatenciesMs.map {
324
+ mapOf("stageId" to it.stageId, "latencyMs" to it.latencyMs.toDouble())
325
+ }
326
+ return out
327
+ }
328
+
329
+ fun encodeToolCall(call: XybridToolCall): Map<String, Any?> =
330
+ mapOf("id" to call.id, "name" to call.name, "argumentsJson" to call.argumentsJson)
331
+
332
+ fun encodeResult(result: XybridResult): Map<String, Any?> {
333
+ val out = linkedMapOf<String, Any?>(
334
+ "envelope" to encodeEnvelope(result.envelope),
335
+ "outputType" to encodeOutputType(result.outputType),
336
+ "modelId" to result.modelId,
337
+ "latencyMs" to result.latencyMs.toDouble(),
338
+ "executionTarget" to encodeExecutionTarget(result.executionTarget),
339
+ "metrics" to encodeMetrics(result.metrics),
340
+ "toolCalls" to result.toolCalls.map(::encodeToolCall),
341
+ )
342
+ result.reasoningContent?.let { out["reasoningContent"] = it }
343
+ return out
344
+ }
345
+
346
+ fun encodeStreamToken(token: XybridStreamToken): Map<String, Any?> {
347
+ val out = linkedMapOf<String, Any?>(
348
+ "token" to token.token,
349
+ // u64 → Double: JS numbers are exact to 2^53.
350
+ "index" to token.index.toDouble(),
351
+ "cumulativeText" to token.cumulativeText,
352
+ "toolCalls" to token.toolCalls.map(::encodeToolCall),
353
+ )
354
+ token.tokenId?.let { out["tokenId"] = it.toDouble() }
355
+ token.finishReason?.let { out["finishReason"] = it }
356
+ token.rawText?.let { out["rawText"] = it }
357
+ return out
358
+ }
359
+
360
+ fun encodeDownloadStatus(status: XybridDownloadStatus): Map<String, Any?> {
361
+ val out = linkedMapOf<String, Any?>(
362
+ "state" to when (status.state) {
363
+ XybridDownloadState.DOWNLOADING -> "downloading"
364
+ XybridDownloadState.READY -> "ready"
365
+ XybridDownloadState.FAILED -> "failed"
366
+ XybridDownloadState.CANCELLED -> "cancelled"
367
+ },
368
+ "progress" to status.progress.toDouble(),
369
+ "downloadedBytes" to status.downloadedBytes.toDouble(),
370
+ )
371
+ // Absent, not 0, when the source declares no size.
372
+ status.totalBytes?.let { out["totalBytes"] = it.toDouble() }
373
+ return out
374
+ }
375
+
376
+ fun encodeVoice(voice: XybridVoiceInfo): Map<String, Any?> {
377
+ val out = linkedMapOf<String, Any?>("id" to voice.id, "name" to voice.name)
378
+ voice.gender?.let { out["gender"] = it }
379
+ voice.language?.let { out["language"] = it }
380
+ voice.style?.let { out["style"] = it }
381
+ return out
382
+ }
383
+
384
+ fun encodeCacheStatus(status: XybridCacheStatus): Map<String, Any?> = mapOf(
385
+ "totalSizeBytes" to status.totalSizeBytes.toDouble(),
386
+ "entryCount" to status.entryCount.toDouble(),
387
+ "modelCount" to status.modelCount.toDouble(),
388
+ "extractedModelCount" to status.extractedModelCount.toDouble(),
389
+ "cacheRoot" to status.cacheRoot,
390
+ )
391
+
392
+ fun encodeCacheEntry(entry: XybridCacheEntry): Map<String, Any?> = mapOf(
393
+ "modelId" to entry.modelId,
394
+ "location" to when (entry.location) {
395
+ XybridCacheEntryLocation.REGISTRY -> "registry"
396
+ XybridCacheEntryLocation.EXTRACTED -> "extracted"
397
+ XybridCacheEntryLocation.HUGGING_FACE -> "huggingFace"
398
+ XybridCacheEntryLocation.HUGGING_FACE_HUB -> "huggingFaceHub"
399
+ },
400
+ "path" to entry.path,
401
+ "sizeBytes" to entry.sizeBytes.toDouble(),
402
+ )
403
+
404
+ fun encodeStageResult(stage: XybridStageResult): Map<String, Any?> = mapOf(
405
+ "stageId" to stage.stageId,
406
+ "envelope" to encodeEnvelope(stage.envelope),
407
+ "outputType" to encodeOutputType(stage.outputType),
408
+ "latencyMs" to stage.latencyMs.toDouble(),
409
+ "executionTarget" to encodeExecutionTarget(stage.executionTarget),
410
+ "metrics" to encodeMetrics(stage.metrics),
411
+ )
412
+
413
+ fun encodePipelineResult(result: XybridPipelineResult): Map<String, Any?> = mapOf(
414
+ "envelope" to encodeEnvelope(result.envelope),
415
+ "outputType" to encodeOutputType(result.outputType),
416
+ "latencyMs" to result.latencyMs.toDouble(),
417
+ "stages" to result.stages.map(::encodeStageResult),
418
+ )
419
+
420
+ fun encodePartial(partial: XybridPartialResult): Map<String, Any?> = mapOf(
421
+ "text" to partial.text,
422
+ "isStable" to partial.isStable,
423
+ "chunkSequence" to partial.chunkSequence.toDouble(),
424
+ "audioDurationMs" to partial.audioDurationMs.toDouble(),
425
+ )
426
+
427
+ // -- Live ASR -------------------------------------------------------------------
428
+
429
+ fun decodeStreamingConfig(any: Any?): XybridStreamingConfig {
430
+ val what = "streaming config"
431
+ val o = if (any == null) emptyMap() else obj(any, what)
432
+ val vad = optionalObj(o, "vad", what)
433
+ ?.let { XybridVadMode.Enabled(filePath(string(it, "modelDir", "streaming config vad"))) }
434
+ ?: XybridVadMode.Off
435
+ return XybridStreamingConfig(
436
+ sampleRate = optionalUInt(o, "sampleRate", what) ?: 16_000u,
437
+ vad = vad,
438
+ vadThreshold = optionalFloat(o, "vadThreshold", what) ?: 0.5f,
439
+ language = optionalString(o, "language", what),
440
+ audioCtx = optionalUInt(o, "audioCtx", what),
441
+ )
442
+ }
443
+
444
+ // -- Errors ---------------------------------------------------------------------
445
+
446
+ /** The stable `xybrid_*` code for an SDK error (see src/errors.ts). */
447
+ fun code(error: XybridError): String = when (error) {
448
+ is XybridError.ModelNotFound -> "xybrid_model_not_found"
449
+ is XybridError.DirectoryNotFound -> "xybrid_directory_not_found"
450
+ is XybridError.MetadataNotFound -> "xybrid_metadata_not_found"
451
+ is XybridError.MetadataInvalid -> "xybrid_metadata_invalid"
452
+ is XybridError.LoadError -> "xybrid_load_error"
453
+ is XybridError.InferenceError -> "xybrid_inference_error"
454
+ is XybridError.AbortedForCloudFallback -> "xybrid_aborted_cloud_fallback"
455
+ is XybridError.StreamingNotSupported -> "xybrid_streaming_unsupported"
456
+ is XybridError.NotLoaded -> "xybrid_not_loaded"
457
+ is XybridError.ConfigError -> "xybrid_config_error"
458
+ is XybridError.NetworkError -> "xybrid_network_error"
459
+ is XybridError.Offline -> "xybrid_offline"
460
+ is XybridError.IoError -> "xybrid_io_error"
461
+ is XybridError.CacheError -> "xybrid_cache_error"
462
+ is XybridError.PipelineError -> "xybrid_pipeline_error"
463
+ is XybridError.CircuitOpen -> "xybrid_circuit_open"
464
+ is XybridError.RateLimited -> "xybrid_rate_limited"
465
+ is XybridError.Timeout -> "xybrid_timeout"
466
+ is XybridError.MissingArtifact -> "xybrid_missing_artifact"
467
+ is XybridError.UnsupportedModelCapability -> "xybrid_unsupported_model_capability"
468
+ is XybridError.UnsupportedBackendCapability -> "xybrid_unsupported_backend_capability"
469
+ is XybridError.InvalidImage -> "xybrid_invalid_image"
470
+ is XybridError.Cancelled -> "xybrid_cancelled"
471
+ }
472
+
473
+ /** `(code, message)` for anything a bridged call can throw. */
474
+ fun rejection(error: Throwable): Pair<String, String> = when (error) {
475
+ is XybridError -> code(error) to (error.message ?: "Xybrid error")
476
+ is BridgeException.InvalidArgument -> "xybrid_invalid_argument" to (error.message ?: "invalid argument")
477
+ is BridgeException.Handle -> "xybrid_handle" to (error.message ?: "unknown handle")
478
+ is BridgeException.Config -> "xybrid_config_error" to (error.message ?: "configuration conflict")
479
+ else -> "xybrid_unknown" to (error.message ?: error.toString())
480
+ }
481
+ }
@@ -0,0 +1,139 @@
1
+ package ai.xybrid.reactnative
2
+
3
+ import java.util.UUID
4
+ import java.util.concurrent.ConcurrentHashMap
5
+ import java.util.concurrent.atomic.AtomicBoolean
6
+ import java.util.concurrent.atomic.AtomicInteger
7
+
8
+ /**
9
+ * Every native object JS holds — models, conversation contexts, cancellation
10
+ * tokens, token streams, downloads, pipelines, live-ASR sessions — keyed by an
11
+ * opaque `"<kind>:<uuid>"` string.
12
+ *
13
+ * A BoltFFI handle is a raw owned pointer with no reference count: closing it
14
+ * while another thread is inside a call on it frees memory that call is still
15
+ * using. So every call holds a [Lease] on the entries it touches, and
16
+ * disposing a handle defers `onClose` until the last lease ends. `onDispose`
17
+ * runs at once, for side effects that must not wait (aborting a stream,
18
+ * stopping a session); entries owned by a disposed handle go with it.
19
+ */
20
+ internal class XybridHandles {
21
+ private val entries = ConcurrentHashMap<String, Entry>()
22
+
23
+ private class Entry(
24
+ val value: Any,
25
+ val owner: String?,
26
+ private val onDispose: (() -> Unit)?,
27
+ private val onClose: (() -> Unit)?,
28
+ ) {
29
+ private val leases = AtomicInteger(0)
30
+ private val closed = AtomicBoolean(false)
31
+
32
+ @Volatile
33
+ private var disposed = false
34
+
35
+ /** Take a lease; `false` once the entry is disposed. */
36
+ fun acquire(): Boolean {
37
+ leases.incrementAndGet()
38
+ if (disposed) {
39
+ release()
40
+ return false
41
+ }
42
+ return true
43
+ }
44
+
45
+ fun release() {
46
+ if (leases.decrementAndGet() == 0 && disposed) close()
47
+ }
48
+
49
+ fun dispose() {
50
+ disposed = true
51
+ onDispose?.invoke()
52
+ if (leases.get() == 0) close()
53
+ }
54
+
55
+ private fun close() {
56
+ if (closed.compareAndSet(false, true)) onClose?.invoke()
57
+ }
58
+ }
59
+
60
+ /** A claim on a live entry. Close it exactly once; extra closes are ignored. */
61
+ class Lease<T : Any> internal constructor(val value: T, private val onRelease: () -> Unit) : AutoCloseable {
62
+ private val released = AtomicBoolean(false)
63
+
64
+ override fun close() {
65
+ if (released.compareAndSet(false, true)) onRelease()
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Register [value] and return its handle. [onClose] defaults to closing an
71
+ * [AutoCloseable] (every BoltFFI handle is one).
72
+ */
73
+ fun insert(
74
+ value: Any,
75
+ kind: String,
76
+ owner: String? = null,
77
+ onDispose: (() -> Unit)? = null,
78
+ onClose: (() -> Unit)? = (value as? AutoCloseable)?.let { closeable -> { closeable.close() } },
79
+ ): String {
80
+ val handle = "$kind:${UUID.randomUUID()}"
81
+ entries[handle] = Entry(value, owner, onDispose, onClose)
82
+ return handle
83
+ }
84
+
85
+ /**
86
+ * Lease the object behind [handle]; a `xybrid_handle` error when it is
87
+ * unknown, disposed, or another kind of object.
88
+ */
89
+ fun <T : Any> lease(handle: String, type: Class<T>): Lease<T> {
90
+ val entry = entries[handle]
91
+ val value = entry?.value
92
+ if (entry == null || !type.isInstance(value) || !entry.acquire()) {
93
+ throw BridgeException.Handle("unknown or released handle: $handle")
94
+ }
95
+ return Lease(type.cast(value)!!, entry::release)
96
+ }
97
+
98
+ /** Like [lease], but `null` instead of an error. */
99
+ fun <T : Any> leaseOrNull(handle: String, type: Class<T>): Lease<T>? =
100
+ try {
101
+ lease(handle, type)
102
+ } catch (e: BridgeException.Handle) {
103
+ null
104
+ }
105
+
106
+ /** Run [block] on the object behind [handle] under a lease. */
107
+ inline fun <T : Any, R> use(handle: String, type: Class<T>, block: (T) -> R): R =
108
+ lease(handle, type).use { block(it.value) }
109
+
110
+ /** Dispose [handle] and every entry it owns. Idempotent. */
111
+ fun dispose(handle: String) {
112
+ val removed = mutableListOf<Entry>()
113
+ entries.remove(handle)?.let(removed::add)
114
+ val owned = entries.entries.filter { it.value.owner == handle }
115
+ for ((key, entry) in owned) {
116
+ if (entries.remove(key, entry)) removed.add(entry)
117
+ }
118
+ removed.forEach(Entry::dispose)
119
+ }
120
+
121
+ /** Dispose everything (module invalidation: reload, teardown). */
122
+ fun disposeAll() {
123
+ val removed = entries.keys.toList().mapNotNull { entries.remove(it) }
124
+ removed.forEach(Entry::dispose)
125
+ }
126
+
127
+ /** Run [action] on every live object of type [T], each under a lease. */
128
+ fun <T : Any> forEachLive(type: Class<T>, action: (T) -> Unit) {
129
+ for (entry in entries.values) {
130
+ val value = entry.value
131
+ if (!type.isInstance(value) || !entry.acquire()) continue
132
+ try {
133
+ action(type.cast(value)!!)
134
+ } finally {
135
+ entry.release()
136
+ }
137
+ }
138
+ }
139
+ }