@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,508 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Translation between the bolt Swift types (xybrid_bolt.swift) and the
|
|
4
|
+
// Foundation values React Native passes across the TurboModule boundary.
|
|
5
|
+
//
|
|
6
|
+
// Pure functions only — no SDK calls, no module state — so
|
|
7
|
+
// tests/swift (scripts/test-ios.sh) can exercise them against the real bolt
|
|
8
|
+
// records on a Mac host. The wire shapes are defined once, in src/wire.ts;
|
|
9
|
+
// every convenience field (voiceId, speed, sampleRate…) was already folded
|
|
10
|
+
// into envelope metadata on the JS side, so envelopes here are just a payload
|
|
11
|
+
// kind plus string metadata, exactly like `XybridEnvelope`.
|
|
12
|
+
//
|
|
13
|
+
// Names avoid every type alias in Xybrid.swift (`Envelope`, `Result`,
|
|
14
|
+
// `Model`, …), which compiles into the same module.
|
|
15
|
+
|
|
16
|
+
/// A JS object as React Native hands it over.
|
|
17
|
+
typealias JSObject = [String: Any]
|
|
18
|
+
|
|
19
|
+
/// A failure raised by the bridge itself rather than the SDK.
|
|
20
|
+
enum BridgeError: Error {
|
|
21
|
+
/// A JS argument could not be decoded. → `xybrid_invalid_argument`
|
|
22
|
+
case invalidArgument(String)
|
|
23
|
+
/// Unknown, released, or wrong-kind handle. → `xybrid_handle`
|
|
24
|
+
case handle(String)
|
|
25
|
+
/// A configuration conflict. → `xybrid_config_error`
|
|
26
|
+
case config(String)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enum XybridCodec {
|
|
30
|
+
// MARK: - Reading JS values
|
|
31
|
+
|
|
32
|
+
/// The value for `key`, treating `NSNull` (JS `null`) as absent.
|
|
33
|
+
static func value(_ object: JSObject, _ key: String) -> Any? {
|
|
34
|
+
guard let value = object[key], !(value is NSNull) else { return nil }
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static func object(_ any: Any?, _ what: String) throws -> JSObject {
|
|
39
|
+
guard let object = any as? JSObject else {
|
|
40
|
+
throw BridgeError.invalidArgument("\(what) must be an object")
|
|
41
|
+
}
|
|
42
|
+
return object
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static func optionalObject(_ object: JSObject, _ key: String, _ what: String) throws -> JSObject? {
|
|
46
|
+
guard let raw = value(object, key) else { return nil }
|
|
47
|
+
return try self.object(raw, what)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static func string(_ object: JSObject, _ key: String, _ what: String) throws -> String {
|
|
51
|
+
guard let string = value(object, key) as? String else {
|
|
52
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' must be a string")
|
|
53
|
+
}
|
|
54
|
+
return string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static func optionalString(_ object: JSObject, _ key: String, _ what: String) throws -> String? {
|
|
58
|
+
guard let raw = value(object, key) else { return nil }
|
|
59
|
+
guard let string = raw as? String else {
|
|
60
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' must be a string")
|
|
61
|
+
}
|
|
62
|
+
return string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static func optionalBool(_ object: JSObject, _ key: String, _ what: String) throws -> Bool? {
|
|
66
|
+
guard let raw = value(object, key) else { return nil }
|
|
67
|
+
// JS booleans arrive as NSNumber-backed `Bool`; accept only real booleans.
|
|
68
|
+
guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
|
|
69
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' must be a boolean")
|
|
70
|
+
}
|
|
71
|
+
return number.boolValue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static func optionalDouble(_ object: JSObject, _ key: String, _ what: String) throws -> Double? {
|
|
75
|
+
guard let raw = value(object, key) else { return nil }
|
|
76
|
+
guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
|
|
77
|
+
number.doubleValue.isFinite else {
|
|
78
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' must be a finite number")
|
|
79
|
+
}
|
|
80
|
+
return number.doubleValue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static func optionalFloat(_ object: JSObject, _ key: String, _ what: String) throws -> Float? {
|
|
84
|
+
try optionalDouble(object, key, what).map { Float($0) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// A non-negative integer that fits in `UInt32`; fractions truncate.
|
|
88
|
+
static func optionalUInt32(_ object: JSObject, _ key: String, _ what: String) throws -> UInt32? {
|
|
89
|
+
guard let double = try optionalDouble(object, key, what) else { return nil }
|
|
90
|
+
return try uint32(double, "\(what): '\(key)'")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static func uint32(_ double: Double, _ what: String) throws -> UInt32 {
|
|
94
|
+
guard double >= 0, double <= Double(UInt32.max) else {
|
|
95
|
+
throw BridgeError.invalidArgument("\(what) must be between 0 and \(UInt32.max)")
|
|
96
|
+
}
|
|
97
|
+
return UInt32(double)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static func stringArray(_ object: JSObject, _ key: String, _ what: String) throws -> [String] {
|
|
101
|
+
guard let raw = value(object, key) else { return [] }
|
|
102
|
+
guard let strings = raw as? [String] else {
|
|
103
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' must be an array of strings")
|
|
104
|
+
}
|
|
105
|
+
return strings
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static func base64(_ object: JSObject, _ key: String, _ what: String) throws -> Data {
|
|
109
|
+
guard let data = decodeBase64(try string(object, key, what)) else {
|
|
110
|
+
throw BridgeError.invalidArgument("\(what): '\(key)' is not valid base64")
|
|
111
|
+
}
|
|
112
|
+
return data
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Strict standard base64 (whitespace ignored), or `nil` — the same rule
|
|
116
|
+
/// the Android codec applies, so garbage fails the same way everywhere.
|
|
117
|
+
static func decodeBase64(_ encoded: String) -> Data? {
|
|
118
|
+
Data(base64Encoded: String(encoded.filter { !$0.isWhitespace }))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Accept plain paths and `file://` URLs (what Expo's file APIs hand out).
|
|
122
|
+
static func filePath(_ raw: String) -> String {
|
|
123
|
+
guard raw.hasPrefix("file://"), let url = URL(string: raw), url.isFileURL else { return raw }
|
|
124
|
+
return url.path
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Little-endian Float32 PCM from base64.
|
|
128
|
+
static func float32Samples(_ encoded: String) throws -> [Float] {
|
|
129
|
+
guard let data = decodeBase64(encoded) else {
|
|
130
|
+
throw BridgeError.invalidArgument("samples are not valid base64")
|
|
131
|
+
}
|
|
132
|
+
guard data.count % MemoryLayout<Float>.size == 0 else {
|
|
133
|
+
throw BridgeError.invalidArgument("samples must be whole Float32 values (\(data.count) bytes)")
|
|
134
|
+
}
|
|
135
|
+
var samples = [Float](repeating: 0, count: data.count / MemoryLayout<Float>.size)
|
|
136
|
+
_ = samples.withUnsafeMutableBytes { data.copyBytes(to: $0) }
|
|
137
|
+
return samples.map { Float(bitPattern: UInt32(littleEndian: $0.bitPattern)) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Envelopes
|
|
141
|
+
|
|
142
|
+
static func decodeEnvelope(_ any: Any?) throws -> XybridEnvelope {
|
|
143
|
+
let object = try self.object(any, "envelope")
|
|
144
|
+
let kind = try string(object, "kind", "envelope")
|
|
145
|
+
let what = "\(kind) envelope"
|
|
146
|
+
let payload: XybridEnvelopeKind
|
|
147
|
+
switch kind {
|
|
148
|
+
case "text":
|
|
149
|
+
payload = .text(text: try string(object, "text", what))
|
|
150
|
+
case "audio":
|
|
151
|
+
payload = .audio(bytes: try base64(object, "bytesBase64", what))
|
|
152
|
+
case "embedding":
|
|
153
|
+
guard let numbers = value(object, "data") as? [NSNumber] else {
|
|
154
|
+
throw BridgeError.invalidArgument("\(what): 'data' must be an array of numbers")
|
|
155
|
+
}
|
|
156
|
+
payload = .embedding(values: numbers.map { $0.floatValue })
|
|
157
|
+
case "image":
|
|
158
|
+
payload = .image(bytes: try base64(object, "bytesBase64", what),
|
|
159
|
+
format: try string(object, "format", what))
|
|
160
|
+
case "multipart":
|
|
161
|
+
guard let parts = value(object, "parts") as? [Any] else {
|
|
162
|
+
throw BridgeError.invalidArgument("\(what): 'parts' must be an array")
|
|
163
|
+
}
|
|
164
|
+
payload = .multiPart(parts: try parts.map(decodeEnvelope))
|
|
165
|
+
default:
|
|
166
|
+
throw BridgeError.invalidArgument("unknown envelope kind '\(kind)'")
|
|
167
|
+
}
|
|
168
|
+
return XybridEnvelope(kind: payload, metadata: try decodeMetadata(object["metadata"]))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static func decodeMetadata(_ any: Any?) throws -> [XybridMetadataEntry] {
|
|
172
|
+
guard let any, !(any is NSNull) else { return [] }
|
|
173
|
+
guard let object = any as? JSObject else {
|
|
174
|
+
throw BridgeError.invalidArgument("envelope metadata must be an object of strings")
|
|
175
|
+
}
|
|
176
|
+
return try object.keys.sorted().map { key in
|
|
177
|
+
guard let value = object[key] as? String else {
|
|
178
|
+
throw BridgeError.invalidArgument("envelope metadata '\(key)' must be a string")
|
|
179
|
+
}
|
|
180
|
+
return XybridMetadataEntry(key: key, value: value)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
static func encodeEnvelope(_ envelope: XybridEnvelope) -> JSObject {
|
|
185
|
+
var out: JSObject = [
|
|
186
|
+
"metadata": Dictionary(envelope.metadata.map { ($0.key, $0.value) },
|
|
187
|
+
uniquingKeysWith: { _, last in last }),
|
|
188
|
+
]
|
|
189
|
+
switch envelope.kind {
|
|
190
|
+
case .text(let text):
|
|
191
|
+
out["kind"] = "text"
|
|
192
|
+
out["text"] = text
|
|
193
|
+
case .audio(let bytes):
|
|
194
|
+
out["kind"] = "audio"
|
|
195
|
+
out["bytesBase64"] = bytes.base64EncodedString()
|
|
196
|
+
case .embedding(let values):
|
|
197
|
+
out["kind"] = "embedding"
|
|
198
|
+
out["data"] = values.map { Double($0) }
|
|
199
|
+
case .image(let bytes, let format):
|
|
200
|
+
out["kind"] = "image"
|
|
201
|
+
out["bytesBase64"] = bytes.base64EncodedString()
|
|
202
|
+
out["format"] = format
|
|
203
|
+
case .multiPart(let parts):
|
|
204
|
+
out["kind"] = "multipart"
|
|
205
|
+
out["parts"] = parts.map(encodeEnvelope)
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// MARK: - Generation and run options
|
|
211
|
+
|
|
212
|
+
static func decodeToolDefinition(_ any: Any) throws -> XybridToolDefinition {
|
|
213
|
+
let object = try self.object(any, "tool")
|
|
214
|
+
return XybridToolDefinition(
|
|
215
|
+
name: try string(object, "name", "tool"),
|
|
216
|
+
description: try string(object, "description", "tool"),
|
|
217
|
+
parametersJson: try string(object, "parametersJson", "tool")
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
static func decodeGenerationConfig(_ object: JSObject) throws -> XybridGenerationConfig {
|
|
222
|
+
let what = "generationConfig"
|
|
223
|
+
let tools = try (value(object, "tools") as? [Any] ?? []).map(decodeToolDefinition)
|
|
224
|
+
return XybridGenerationConfig(
|
|
225
|
+
maxTokens: try optionalUInt32(object, "maxTokens", what),
|
|
226
|
+
temperature: try optionalFloat(object, "temperature", what),
|
|
227
|
+
topP: try optionalFloat(object, "topP", what),
|
|
228
|
+
minP: try optionalFloat(object, "minP", what),
|
|
229
|
+
topK: try optionalUInt32(object, "topK", what),
|
|
230
|
+
repetitionPenalty: try optionalFloat(object, "repetitionPenalty", what),
|
|
231
|
+
stopSequences: try stringArray(object, "stopSequences", what),
|
|
232
|
+
grammar: try optionalString(object, "grammar", what),
|
|
233
|
+
tools: tools
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
static func encodeGenerationConfig(_ config: XybridGenerationConfig) -> JSObject {
|
|
238
|
+
var out: JSObject = [
|
|
239
|
+
"stopSequences": config.stopSequences,
|
|
240
|
+
"tools": config.tools.map {
|
|
241
|
+
["name": $0.name, "description": $0.description, "parametersJson": $0.parametersJson]
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
if let value = config.maxTokens { out["maxTokens"] = value }
|
|
245
|
+
if let value = config.temperature { out["temperature"] = Double(value) }
|
|
246
|
+
if let value = config.topP { out["topP"] = Double(value) }
|
|
247
|
+
if let value = config.minP { out["minP"] = Double(value) }
|
|
248
|
+
if let value = config.topK { out["topK"] = value }
|
|
249
|
+
if let value = config.repetitionPenalty { out["repetitionPenalty"] = Double(value) }
|
|
250
|
+
if let value = config.grammar { out["grammar"] = value }
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
static func decodeAbortSignal(_ raw: String) throws -> XybridAbortSignal {
|
|
255
|
+
switch raw {
|
|
256
|
+
case "memoryPressureWarn": return .memoryPressureWarn
|
|
257
|
+
case "memoryPressureCritical": return .memoryPressureCritical
|
|
258
|
+
case "thermalHot": return .thermalHot
|
|
259
|
+
case "thermalCritical": return .thermalCritical
|
|
260
|
+
default: throw BridgeError.invalidArgument("unknown abort signal '\(raw)'")
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// Run options plus the two handles that ride along with them.
|
|
265
|
+
struct RunRequest {
|
|
266
|
+
var options: XybridRunOptions?
|
|
267
|
+
var context: String?
|
|
268
|
+
var cancel: String?
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
static func decodeRunRequest(_ any: Any?) throws -> RunRequest {
|
|
272
|
+
guard let any, !(any is NSNull) else { return RunRequest() }
|
|
273
|
+
let object = try self.object(any, "run options")
|
|
274
|
+
let what = "run options"
|
|
275
|
+
let options = XybridRunOptions(
|
|
276
|
+
generationConfig: try optionalObject(object, "generationConfig", what).map(decodeGenerationConfig),
|
|
277
|
+
abortOn: try stringArray(object, "abortOn", what).map(decodeAbortSignal),
|
|
278
|
+
fallbackToCloud: try optionalBool(object, "fallbackToCloud", what) ?? false,
|
|
279
|
+
maxGraceTokens: try optionalUInt32(object, "maxGraceTokens", what) ?? 0,
|
|
280
|
+
correlationId: try optionalString(object, "correlationId", what)
|
|
281
|
+
)
|
|
282
|
+
return RunRequest(
|
|
283
|
+
options: options,
|
|
284
|
+
context: try optionalString(object, "context", what),
|
|
285
|
+
cancel: try optionalString(object, "cancel", what)
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
static func decodeToolResult(_ any: Any) throws -> XybridToolResult {
|
|
290
|
+
let object = try self.object(any, "tool result")
|
|
291
|
+
return XybridToolResult(
|
|
292
|
+
callId: try string(object, "callId", "tool result"),
|
|
293
|
+
name: try string(object, "name", "tool result"),
|
|
294
|
+
contentJson: try string(object, "contentJson", "tool result")
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// MARK: - Results
|
|
299
|
+
|
|
300
|
+
static func encodeOutputType(_ type: XybridOutputType) -> String {
|
|
301
|
+
switch type {
|
|
302
|
+
case .text: return "text"
|
|
303
|
+
case .audio: return "audio"
|
|
304
|
+
case .embedding: return "embedding"
|
|
305
|
+
case .unknown: return "unknown"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
static func encodeExecutionTarget(_ target: XybridExecutionTarget) -> String {
|
|
310
|
+
target == .cloud ? "cloud" : "local"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Metrics in native stage order; LLM-only values only when reported.
|
|
314
|
+
static func encodeMetrics(_ metrics: XybridInferenceMetrics) -> JSObject {
|
|
315
|
+
var out: JSObject = [
|
|
316
|
+
"totalMs": metrics.totalMs,
|
|
317
|
+
"stageLatenciesMs": metrics.stageLatenciesMs.map {
|
|
318
|
+
["stageId": $0.stageId, "latencyMs": $0.latencyMs]
|
|
319
|
+
},
|
|
320
|
+
]
|
|
321
|
+
if let value = metrics.ttftMs { out["ttftMs"] = value }
|
|
322
|
+
if let value = metrics.tokensPerSecond { out["tokensPerSecond"] = Double(value) }
|
|
323
|
+
if let value = metrics.prefillTps { out["prefillTps"] = Double(value) }
|
|
324
|
+
if let value = metrics.decodeTps { out["decodeTps"] = Double(value) }
|
|
325
|
+
if let value = metrics.tokensOut { out["tokensOut"] = value }
|
|
326
|
+
return out
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static func encodeToolCall(_ call: XybridToolCall) -> JSObject {
|
|
330
|
+
["id": call.id, "name": call.name, "argumentsJson": call.argumentsJson]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
static func encodeResult(_ result: XybridResult) -> JSObject {
|
|
334
|
+
var out: JSObject = [
|
|
335
|
+
"envelope": encodeEnvelope(result.envelope),
|
|
336
|
+
"outputType": encodeOutputType(result.outputType),
|
|
337
|
+
"modelId": result.modelId,
|
|
338
|
+
"latencyMs": result.latencyMs,
|
|
339
|
+
"executionTarget": encodeExecutionTarget(result.executionTarget),
|
|
340
|
+
"metrics": encodeMetrics(result.metrics),
|
|
341
|
+
"toolCalls": result.toolCalls.map(encodeToolCall),
|
|
342
|
+
]
|
|
343
|
+
if let reasoning = result.reasoningContent { out["reasoningContent"] = reasoning }
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
static func encodeStreamToken(_ token: XybridStreamToken) -> JSObject {
|
|
348
|
+
var out: JSObject = [
|
|
349
|
+
"token": token.token,
|
|
350
|
+
// u64 → Double: JS numbers are exact to 2^53; Int() could trap.
|
|
351
|
+
"index": Double(token.index),
|
|
352
|
+
"cumulativeText": token.cumulativeText,
|
|
353
|
+
"toolCalls": token.toolCalls.map(encodeToolCall),
|
|
354
|
+
]
|
|
355
|
+
if let id = token.tokenId { out["tokenId"] = Double(id) }
|
|
356
|
+
if let reason = token.finishReason { out["finishReason"] = reason }
|
|
357
|
+
if let raw = token.rawText { out["rawText"] = raw }
|
|
358
|
+
return out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
static func encodeDownloadStatus(_ status: XybridDownloadStatus) -> JSObject {
|
|
362
|
+
let state: String
|
|
363
|
+
switch status.state {
|
|
364
|
+
case .downloading: state = "downloading"
|
|
365
|
+
case .ready: state = "ready"
|
|
366
|
+
case .failed: state = "failed"
|
|
367
|
+
case .cancelled: state = "cancelled"
|
|
368
|
+
}
|
|
369
|
+
var out: JSObject = [
|
|
370
|
+
"state": state,
|
|
371
|
+
"progress": Double(status.progress),
|
|
372
|
+
"downloadedBytes": Double(status.downloadedBytes),
|
|
373
|
+
]
|
|
374
|
+
// Absent, not 0, when the source declares no size.
|
|
375
|
+
if let total = status.totalBytes { out["totalBytes"] = Double(total) }
|
|
376
|
+
return out
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
static func encodeVoice(_ voice: XybridVoiceInfo) -> JSObject {
|
|
380
|
+
var out: JSObject = ["id": voice.id, "name": voice.name]
|
|
381
|
+
if let gender = voice.gender { out["gender"] = gender }
|
|
382
|
+
if let language = voice.language { out["language"] = language }
|
|
383
|
+
if let style = voice.style { out["style"] = style }
|
|
384
|
+
return out
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
static func encodeCacheStatus(_ status: XybridCacheStatus) -> JSObject {
|
|
388
|
+
[
|
|
389
|
+
"totalSizeBytes": Double(status.totalSizeBytes),
|
|
390
|
+
"entryCount": status.entryCount,
|
|
391
|
+
"modelCount": status.modelCount,
|
|
392
|
+
"extractedModelCount": status.extractedModelCount,
|
|
393
|
+
"cacheRoot": status.cacheRoot,
|
|
394
|
+
]
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
static func encodeCacheEntry(_ entry: XybridCacheEntry) -> JSObject {
|
|
398
|
+
let location: String
|
|
399
|
+
switch entry.location {
|
|
400
|
+
case .registry: location = "registry"
|
|
401
|
+
case .extracted: location = "extracted"
|
|
402
|
+
case .huggingFace: location = "huggingFace"
|
|
403
|
+
case .huggingFaceHub: location = "huggingFaceHub"
|
|
404
|
+
}
|
|
405
|
+
return [
|
|
406
|
+
"modelId": entry.modelId,
|
|
407
|
+
"location": location,
|
|
408
|
+
"path": entry.path,
|
|
409
|
+
"sizeBytes": Double(entry.sizeBytes),
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
static func encodeStageResult(_ stage: XybridStageResult) -> JSObject {
|
|
414
|
+
[
|
|
415
|
+
"stageId": stage.stageId,
|
|
416
|
+
"envelope": encodeEnvelope(stage.envelope),
|
|
417
|
+
"outputType": encodeOutputType(stage.outputType),
|
|
418
|
+
"latencyMs": stage.latencyMs,
|
|
419
|
+
"executionTarget": encodeExecutionTarget(stage.executionTarget),
|
|
420
|
+
"metrics": encodeMetrics(stage.metrics),
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
static func encodePipelineResult(_ result: XybridPipelineResult) -> JSObject {
|
|
425
|
+
[
|
|
426
|
+
"envelope": encodeEnvelope(result.envelope),
|
|
427
|
+
"outputType": encodeOutputType(result.outputType),
|
|
428
|
+
"latencyMs": result.latencyMs,
|
|
429
|
+
"stages": result.stages.map(encodeStageResult),
|
|
430
|
+
]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
static func encodePartial(_ partial: XybridPartialResult) -> JSObject {
|
|
434
|
+
[
|
|
435
|
+
"text": partial.text,
|
|
436
|
+
"isStable": partial.isStable,
|
|
437
|
+
"chunkSequence": Double(partial.chunkSequence),
|
|
438
|
+
"audioDurationMs": Double(partial.audioDurationMs),
|
|
439
|
+
]
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// MARK: - Live ASR
|
|
443
|
+
|
|
444
|
+
static func decodeStreamingConfig(_ any: Any?) throws -> XybridStreamingConfig {
|
|
445
|
+
let object: JSObject = (any == nil || any is NSNull) ? [:] : try self.object(any, "streaming config")
|
|
446
|
+
let what = "streaming config"
|
|
447
|
+
let vad: XybridVadMode
|
|
448
|
+
if let vadObject = try optionalObject(object, "vad", what) {
|
|
449
|
+
vad = .enabled(modelDir: filePath(try string(vadObject, "modelDir", "streaming config vad")))
|
|
450
|
+
} else {
|
|
451
|
+
vad = .off
|
|
452
|
+
}
|
|
453
|
+
return XybridStreamingConfig(
|
|
454
|
+
sampleRate: try optionalUInt32(object, "sampleRate", what) ?? 16_000,
|
|
455
|
+
vad: vad,
|
|
456
|
+
vadThreshold: try optionalFloat(object, "vadThreshold", what) ?? 0.5,
|
|
457
|
+
language: try optionalString(object, "language", what),
|
|
458
|
+
audioCtx: try optionalUInt32(object, "audioCtx", what)
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// MARK: - Errors
|
|
463
|
+
|
|
464
|
+
/// The stable `xybrid_*` code for an SDK error (see src/errors.ts).
|
|
465
|
+
static func code(for error: XybridError) -> String {
|
|
466
|
+
switch error {
|
|
467
|
+
case .modelNotFound: return "xybrid_model_not_found"
|
|
468
|
+
case .directoryNotFound: return "xybrid_directory_not_found"
|
|
469
|
+
case .metadataNotFound: return "xybrid_metadata_not_found"
|
|
470
|
+
case .metadataInvalid: return "xybrid_metadata_invalid"
|
|
471
|
+
case .loadError: return "xybrid_load_error"
|
|
472
|
+
case .inferenceError: return "xybrid_inference_error"
|
|
473
|
+
case .abortedForCloudFallback: return "xybrid_aborted_cloud_fallback"
|
|
474
|
+
case .streamingNotSupported: return "xybrid_streaming_unsupported"
|
|
475
|
+
case .notLoaded: return "xybrid_not_loaded"
|
|
476
|
+
case .configError: return "xybrid_config_error"
|
|
477
|
+
case .networkError: return "xybrid_network_error"
|
|
478
|
+
case .offline: return "xybrid_offline"
|
|
479
|
+
case .ioError: return "xybrid_io_error"
|
|
480
|
+
case .cacheError: return "xybrid_cache_error"
|
|
481
|
+
case .pipelineError: return "xybrid_pipeline_error"
|
|
482
|
+
case .circuitOpen: return "xybrid_circuit_open"
|
|
483
|
+
case .rateLimited: return "xybrid_rate_limited"
|
|
484
|
+
case .timeout: return "xybrid_timeout"
|
|
485
|
+
case .missingArtifact: return "xybrid_missing_artifact"
|
|
486
|
+
case .unsupportedModelCapability: return "xybrid_unsupported_model_capability"
|
|
487
|
+
case .unsupportedBackendCapability: return "xybrid_unsupported_backend_capability"
|
|
488
|
+
case .invalidImage: return "xybrid_invalid_image"
|
|
489
|
+
case .cancelled: return "xybrid_cancelled"
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// `(code, message)` for anything a bridged call can throw.
|
|
494
|
+
static func rejection(for error: Error) -> (code: String, message: String) {
|
|
495
|
+
switch error {
|
|
496
|
+
case let error as XybridError:
|
|
497
|
+
return (code(for: error), error.errorDescription ?? "Xybrid error")
|
|
498
|
+
case BridgeError.invalidArgument(let message):
|
|
499
|
+
return ("xybrid_invalid_argument", message)
|
|
500
|
+
case BridgeError.handle(let message):
|
|
501
|
+
return ("xybrid_handle", message)
|
|
502
|
+
case BridgeError.config(let message):
|
|
503
|
+
return ("xybrid_config_error", message)
|
|
504
|
+
default:
|
|
505
|
+
return ("xybrid_unknown", error.localizedDescription)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Every native object JS holds — models, conversation contexts, cancellation
|
|
4
|
+
/// tokens, token streams, downloads, pipelines, live-ASR sessions — keyed by
|
|
5
|
+
/// an opaque `"<kind>:<uuid>"` string.
|
|
6
|
+
///
|
|
7
|
+
/// Lifetimes are ARC's: in-flight calls hold their own strong references, so
|
|
8
|
+
/// disposing a handle while a call uses it only drops the registry's
|
|
9
|
+
/// reference and the object is freed when that call returns. `onDispose`
|
|
10
|
+
/// runs the side effects that must happen immediately (aborting a stream,
|
|
11
|
+
/// stopping a session) and entries owned by a disposed handle go with it.
|
|
12
|
+
final class XybridHandles {
|
|
13
|
+
private struct Entry {
|
|
14
|
+
let object: AnyObject
|
|
15
|
+
let owner: String?
|
|
16
|
+
let onDispose: (() -> Void)?
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private let lock = NSLock()
|
|
20
|
+
private var entries: [String: Entry] = [:]
|
|
21
|
+
|
|
22
|
+
/// Register `object` and return its new handle.
|
|
23
|
+
func insert(_ object: AnyObject,
|
|
24
|
+
kind: String,
|
|
25
|
+
owner: String? = nil,
|
|
26
|
+
onDispose: (() -> Void)? = nil) -> String {
|
|
27
|
+
let handle = "\(kind):\(UUID().uuidString)"
|
|
28
|
+
lock.lock()
|
|
29
|
+
entries[handle] = Entry(object: object, owner: owner, onDispose: onDispose)
|
|
30
|
+
lock.unlock()
|
|
31
|
+
return handle
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// The live object behind `handle`, or a `xybrid_handle` error when the
|
|
35
|
+
/// handle is unknown, disposed, or names another kind of object.
|
|
36
|
+
func get<T>(_ handle: String, as type: T.Type) throws -> T {
|
|
37
|
+
lock.lock()
|
|
38
|
+
let object = entries[handle]?.object
|
|
39
|
+
lock.unlock()
|
|
40
|
+
guard let typed = object as? T else {
|
|
41
|
+
throw BridgeError.handle("unknown or released handle: \(handle)")
|
|
42
|
+
}
|
|
43
|
+
return typed
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Like `get`, but `nil` instead of an error.
|
|
47
|
+
func find<T>(_ handle: String, as type: T.Type) -> T? {
|
|
48
|
+
try? get(handle, as: type)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Dispose `handle` and every entry it owns. Idempotent.
|
|
52
|
+
func dispose(_ handle: String) {
|
|
53
|
+
lock.lock()
|
|
54
|
+
var removed: [Entry] = []
|
|
55
|
+
if let entry = entries.removeValue(forKey: handle) { removed.append(entry) }
|
|
56
|
+
for (key, entry) in entries where entry.owner == handle {
|
|
57
|
+
entries.removeValue(forKey: key)
|
|
58
|
+
removed.append(entry)
|
|
59
|
+
}
|
|
60
|
+
lock.unlock()
|
|
61
|
+
// Side effects outside the lock: they call into the SDK.
|
|
62
|
+
removed.forEach { $0.onDispose?() }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Dispose everything (module invalidation: reload, teardown).
|
|
66
|
+
func disposeAll() {
|
|
67
|
+
lock.lock()
|
|
68
|
+
let removed = Array(entries.values)
|
|
69
|
+
entries.removeAll()
|
|
70
|
+
lock.unlock()
|
|
71
|
+
removed.forEach { $0.onDispose?() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Every live object of type `T` (e.g. to cancel all tokens on teardown).
|
|
75
|
+
func all<T>(_ type: T.Type) -> [T] {
|
|
76
|
+
lock.lock()
|
|
77
|
+
defer { lock.unlock() }
|
|
78
|
+
return entries.values.compactMap { $0.object as? T }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// A pull-based token stream: the model it runs on (bolt stream ids are
|
|
83
|
+
/// model-scoped) and the stop button that aborts it.
|
|
84
|
+
final class XybridTokenStreamEntry {
|
|
85
|
+
let model: XybridModel
|
|
86
|
+
let streamId: UInt64
|
|
87
|
+
let cancel: XybridCancellationToken
|
|
88
|
+
|
|
89
|
+
init(model: XybridModel, streamId: UInt64, cancel: XybridCancellationToken) {
|
|
90
|
+
self.model = model
|
|
91
|
+
self.streamId = streamId
|
|
92
|
+
self.cancel = cancel
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Abort generation now. Safe while a `streamNext` is in flight: closing
|
|
96
|
+
/// only forgets the session id, and the in-flight pull keeps its own
|
|
97
|
+
/// reference until it returns.
|
|
98
|
+
func abort() {
|
|
99
|
+
cancel.cancel()
|
|
100
|
+
model.streamClose(streamId: streamId)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// A live-ASR session plus a pump that buffers its partial transcripts for
|
|
105
|
+
/// pull-based reads from JS.
|
|
106
|
+
final class XybridSessionEntry {
|
|
107
|
+
let session: XybridStreamingSession
|
|
108
|
+
private let partials: PartialQueue
|
|
109
|
+
|
|
110
|
+
init(session: XybridStreamingSession) {
|
|
111
|
+
self.session = session
|
|
112
|
+
self.partials = PartialQueue(stream: session.partials())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
deinit {
|
|
116
|
+
partials.stop()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// The next partial transcript, or `nil` once the session has finished.
|
|
120
|
+
func nextPartial() async -> XybridPartialResult? {
|
|
121
|
+
await partials.next()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Drains an `AsyncStream` on its own task so JS can pull one element per
|
|
126
|
+
/// call. (An `AsyncStream` iterator must never be awaited from two tasks at
|
|
127
|
+
/// once, which overlapping bridge calls could otherwise do.)
|
|
128
|
+
private final class PartialQueue: @unchecked Sendable {
|
|
129
|
+
/// Partials are cumulative — each supersedes the last — so a reader that
|
|
130
|
+
/// falls this far behind only loses stale text.
|
|
131
|
+
private static let capacity = 64
|
|
132
|
+
|
|
133
|
+
private let lock = NSLock()
|
|
134
|
+
private var buffer: [XybridPartialResult] = []
|
|
135
|
+
private var waiters: [CheckedContinuation<XybridPartialResult?, Never>] = []
|
|
136
|
+
private var finished = false
|
|
137
|
+
private var pump: Task<Void, Never>?
|
|
138
|
+
|
|
139
|
+
init(stream: AsyncStream<XybridPartialResult>) {
|
|
140
|
+
pump = Task.detached { [weak self] in
|
|
141
|
+
for await partial in stream {
|
|
142
|
+
self?.push(partial)
|
|
143
|
+
}
|
|
144
|
+
self?.finish()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func next() async -> XybridPartialResult? {
|
|
149
|
+
await withCheckedContinuation { continuation in
|
|
150
|
+
lock.lock()
|
|
151
|
+
if !buffer.isEmpty {
|
|
152
|
+
let partial = buffer.removeFirst()
|
|
153
|
+
lock.unlock()
|
|
154
|
+
continuation.resume(returning: partial)
|
|
155
|
+
} else if finished {
|
|
156
|
+
lock.unlock()
|
|
157
|
+
continuation.resume(returning: nil)
|
|
158
|
+
} else {
|
|
159
|
+
waiters.append(continuation)
|
|
160
|
+
lock.unlock()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func stop() {
|
|
166
|
+
pump?.cancel()
|
|
167
|
+
finish()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func push(_ partial: XybridPartialResult) {
|
|
171
|
+
lock.lock()
|
|
172
|
+
if !waiters.isEmpty {
|
|
173
|
+
let waiter = waiters.removeFirst()
|
|
174
|
+
lock.unlock()
|
|
175
|
+
waiter.resume(returning: partial)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
buffer.append(partial)
|
|
179
|
+
if buffer.count > Self.capacity { buffer.removeFirst(buffer.count - Self.capacity) }
|
|
180
|
+
lock.unlock()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func finish() {
|
|
184
|
+
lock.lock()
|
|
185
|
+
finished = true
|
|
186
|
+
let pending = waiters
|
|
187
|
+
waiters.removeAll()
|
|
188
|
+
lock.unlock()
|
|
189
|
+
pending.forEach { $0.resume(returning: nil) }
|
|
190
|
+
}
|
|
191
|
+
}
|