@dvai-bridge/ios 4.0.0

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 (60) hide show
  1. package/DVAIBridge.podspec +120 -0
  2. package/LICENSE +51 -0
  3. package/Package.swift +104 -0
  4. package/README.md +199 -0
  5. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
  6. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
  7. package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
  8. package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
  9. package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
  10. package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
  11. package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
  12. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
  13. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
  14. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
  15. package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
  16. package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
  17. package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
  18. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
  19. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
  20. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
  21. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
  22. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
  23. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
  24. package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
  25. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
  26. package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
  27. package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
  28. package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
  29. package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
  30. package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
  31. package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
  32. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
  33. package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
  34. package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
  35. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
  36. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
  37. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
  38. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
  39. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
  40. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
  41. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
  42. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
  43. package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
  44. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
  45. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
  46. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
  47. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
  48. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
  49. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
  50. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
  51. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
  52. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
  53. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
  54. package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
  55. package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
  56. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
  57. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
  58. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
  59. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
  60. package/package.json +19 -0
@@ -0,0 +1,604 @@
1
+ import Foundation
2
+ import CryptoKit
3
+ #if !COCOAPODS
4
+ import Hummingbird
5
+ import HummingbirdCore
6
+ import NIOCore
7
+ import NIOHTTPTypes
8
+ import HTTPTypes
9
+
10
+ /// v3.2 — Pre-routing HTTP proxy for the iOS SDK.
11
+ ///
12
+ /// Architecture mirrors the Android Ktor-based OffloadProxy 1:1:
13
+ ///
14
+ /// consumer app -> http://127.0.0.1:proxyPort/v1/...
15
+ /// |
16
+ /// +-- if local-decision -> http://127.0.0.1:backendPort/v1/...
17
+ /// +-- if offload-decision -> peer baseUrl
18
+ /// (HMAC-SHA256: X-DVAI-Peer-Device-Id +
19
+ /// X-DVAI-App-Id +
20
+ /// X-DVAI-Nonce +
21
+ /// X-DVAI-Signature)
22
+ ///
23
+ /// Lifecycle is owned by `DVAIBridge.shared.start(_:)`. Don't construct
24
+ /// from consumer code.
25
+ ///
26
+ /// Streaming: built on Hummingbird (swift-nio) so SSE responses pipe
27
+ /// through cleanly — when the upstream peer / backend emits chunks,
28
+ /// the consumer sees them incrementally. The earlier Telegraph-based
29
+ /// implementation (v3.2.0-rc) buffered the whole body server-side,
30
+ /// breaking incremental token streaming through the proxy.
31
+ @available(iOS 14.0, macOS 14.0, *)
32
+ public actor OffloadProxy {
33
+
34
+ /// Backend's internal loopback URL (e.g. `http://127.0.0.1:38983`).
35
+ /// `nil` when the SDK is in offload-only mode (no backend).
36
+ public let backendBaseUrl: String?
37
+ public let offloadConfig: OffloadConfig
38
+ public let pairingPolicy: PairingPolicy?
39
+ /// Live snapshot of paired peers — re-read on each request so
40
+ /// runtime additions are honored without restarting the proxy.
41
+ public let peerProvider: @Sendable () async -> [MDNSPeer]
42
+ public let appId: String
43
+ public let selfDeviceId: String
44
+
45
+ private var application: (any ApplicationProtocol)?
46
+ private var serverTask: Task<Void, Error>?
47
+ private var boundPort: Int = -1
48
+ private let session: URLSession
49
+
50
+ public init(
51
+ backendBaseUrl: String?,
52
+ offloadConfig: OffloadConfig,
53
+ pairingPolicy: PairingPolicy?,
54
+ peerProvider: @escaping @Sendable () async -> [MDNSPeer],
55
+ appId: String,
56
+ selfDeviceId: String
57
+ ) {
58
+ self.backendBaseUrl = backendBaseUrl
59
+ self.offloadConfig = offloadConfig
60
+ self.pairingPolicy = pairingPolicy
61
+ self.peerProvider = peerProvider
62
+ self.appId = appId
63
+ self.selfDeviceId = selfDeviceId
64
+
65
+ let cfg = URLSessionConfiguration.default
66
+ cfg.timeoutIntervalForRequest = 600
67
+ cfg.timeoutIntervalForResource = 600
68
+ self.session = URLSession(configuration: cfg)
69
+ }
70
+
71
+ /// Bind the proxy. Tries `basePort..basePort+maxAttempts-1`.
72
+ /// Returns the bound port.
73
+ public func start(basePort: Int, maxAttempts: Int = 16, host: String = "127.0.0.1") async throws -> Int {
74
+ precondition(application == nil, "OffloadProxy already started")
75
+
76
+ var lastError: Error?
77
+ for i in 0..<maxAttempts {
78
+ let port = basePort + i
79
+ do {
80
+ let router = buildRouter()
81
+ let app = Application(
82
+ router: router,
83
+ configuration: .init(
84
+ address: .hostname(host, port: port),
85
+ serverName: "dvai-offload-proxy"
86
+ )
87
+ )
88
+ // Start the server. Application.runService() blocks; we
89
+ // run it in a Task and rely on the bound port being
90
+ // exposed before the first request lands.
91
+ let task = Task<Void, Error> {
92
+ try await app.runService()
93
+ }
94
+ self.application = app
95
+ self.serverTask = task
96
+ self.boundPort = port
97
+ return port
98
+ } catch {
99
+ lastError = error
100
+ continue
101
+ }
102
+ }
103
+ throw NSError(
104
+ domain: "OffloadProxy",
105
+ code: 1,
106
+ userInfo: [NSLocalizedDescriptionKey:
107
+ "OffloadProxy: failed to bind any port in \(basePort)..\(basePort + maxAttempts - 1) (\(lastError?.localizedDescription ?? "no detail"))"]
108
+ )
109
+ }
110
+
111
+ /// Stop the proxy. Idempotent.
112
+ public func stop() async {
113
+ serverTask?.cancel()
114
+ _ = try? await serverTask?.value
115
+ serverTask = nil
116
+ application = nil
117
+ boundPort = -1
118
+ }
119
+
120
+ /// Public bind URL once started; nil before start().
121
+ public func baseUrl() -> String? {
122
+ return boundPort > 0 ? "http://127.0.0.1:\(boundPort)" : nil
123
+ }
124
+
125
+ public func currentPort() -> Int { boundPort }
126
+
127
+ /* ================================================================== *
128
+ * Hummingbird router *
129
+ * ================================================================== */
130
+
131
+ private nonisolated func buildRouter() -> Router<BasicRequestContext> {
132
+ let router = Router(context: BasicRequestContext.self)
133
+ // Catch-all on every method + path — the proxy decides per-request.
134
+ router.on("/**", method: .get, use: { req, ctx in await self.handle(req: req, ctx: ctx) })
135
+ router.on("/**", method: .post, use: { req, ctx in await self.handle(req: req, ctx: ctx) })
136
+ router.on("/**", method: .options, use: { req, ctx in await self.handle(req: req, ctx: ctx) })
137
+ return router
138
+ }
139
+
140
+ private func handle(req: Request, ctx: BasicRequestContext) async -> Response {
141
+ let path = req.uri.path
142
+ let bodyData: Data
143
+ do {
144
+ bodyData = try await collectBody(req.body)
145
+ } catch {
146
+ return jsonResponse(status: .badGateway,
147
+ body: #"{"error":{"type":"proxy_error","code":502,"message":"\#(escapeJson(error.localizedDescription))"}}"#)
148
+ }
149
+
150
+ // v3.2.1 — incoming pairing handshake. The TS-side handler
151
+ // (packages/dvai-bridge-core/src/handlers/dvai/index.ts:110)
152
+ // is wire-compatible with this one: same body shape, same
153
+ // response keys. We special-case the path BEFORE
154
+ // `decideRoute` so the request never tries to forward to a
155
+ // peer or the (nil-in-offload-only) local backend — both
156
+ // would 404/502.
157
+ if path == "/v1/dvai/handshake" {
158
+ return await handleHandshakeRequest(method: req.method, body: bodyData)
159
+ }
160
+
161
+ // Build a lower-cased header map for decision + forwarding.
162
+ var headerMap: [String: String] = [:]
163
+ for f in req.headers {
164
+ headerMap[f.name.canonicalName.lowercased()] = f.value
165
+ }
166
+
167
+ let decision = await decideRoute(path: path, body: bodyData, headers: headerMap)
168
+ switch decision {
169
+ case .local:
170
+ return await forwardToLocal(method: req.method, path: path, body: bodyData, headers: req.headers)
171
+ case .offload(let baseUrl, let peerDeviceId):
172
+ return await forwardToPeer(
173
+ baseUrl: baseUrl,
174
+ peerDeviceId: peerDeviceId,
175
+ method: req.method,
176
+ path: path,
177
+ body: bodyData,
178
+ headers: req.headers
179
+ )
180
+ case .noCapableDevice(let json):
181
+ return jsonResponse(status: .serviceUnavailable, body: json)
182
+ }
183
+ }
184
+
185
+ /* ================================================================== *
186
+ * Decision *
187
+ * ================================================================== */
188
+
189
+ public enum RouteDecision: Sendable {
190
+ case local
191
+ case offload(baseUrl: String, peerDeviceId: String)
192
+ case noCapableDevice(json: String)
193
+ }
194
+
195
+ public func decideRoute(
196
+ path: String,
197
+ body: Data,
198
+ headers: [String: String]
199
+ ) async -> RouteDecision {
200
+ let isChatCompletion = path.hasSuffix("/chat/completions") ||
201
+ path.hasSuffix("/v1/chat/completions")
202
+
203
+ if !isChatCompletion {
204
+ return backendBaseUrl != nil ? .local : .noCapableDevice(json: noLocalBackendError())
205
+ }
206
+
207
+ if !offloadConfig.enabled {
208
+ return backendBaseUrl != nil ? .local : .noCapableDevice(json: noLocalBackendError())
209
+ }
210
+
211
+ let offloadHeader = (headers["x-dvai-offload"] ?? "prefer").lowercased()
212
+ if offloadHeader == "never" {
213
+ return backendBaseUrl != nil ? .local : .noCapableDevice(json: noLocalBackendError())
214
+ }
215
+
216
+ let modelId = readModelId(from: body) ?? ""
217
+ let peers = await peerProvider()
218
+ let best = pickBestPeer(peers: peers, modelId: modelId)
219
+ let threshold = offloadConfig.minLocalCapability
220
+
221
+ // v3.2.1 — paired-peer fallback. `pickBestPeer` filters by
222
+ // `capability[modelId] > 0`, but a freshly-discovered Hub
223
+ // (mDNS-only, no benchmark run yet) advertises an empty
224
+ // capability map → every peer scores 0 → `best == nil` even
225
+ // though the peer is reachable AND we have a valid pairing.
226
+ //
227
+ // Two-stage fallback:
228
+ // (a) prefer a discovered peer that we have an active
229
+ // pairing for (lets us pick up TXT-record updates etc.);
230
+ // (b) if discovery is dark for the paired peer (the macOS
231
+ // Hub case — Node's `multicast-dns` lib can't advertise
232
+ // through mDNSResponder), fall back to the pairing's
233
+ // persisted baseUrl. The pairing is what proves
234
+ // authorisation; the baseUrl is just routing info.
235
+ let pairedFallback: (baseUrl: String, peerDeviceId: String)? = await {
236
+ guard let policy = pairingPolicy else { return nil }
237
+ // (a) pairing + discovered.
238
+ for p in peers {
239
+ if await policy.getActive(peerDeviceId: p.deviceId) != nil {
240
+ return (p.baseUrl, p.deviceId)
241
+ }
242
+ }
243
+ // (b) pairing only — uses the baseUrl captured at
244
+ // pairing time. `getActive` filters out expired records.
245
+ for pairing in await policy.listPairings() {
246
+ if let baseUrl = pairing.baseUrl,
247
+ await policy.getActive(peerDeviceId: pairing.peerDeviceId) != nil {
248
+ return (baseUrl, pairing.peerDeviceId)
249
+ }
250
+ }
251
+ return nil
252
+ }()
253
+
254
+ if offloadHeader == "require" {
255
+ if let best {
256
+ return .offload(baseUrl: best.peer.baseUrl, peerDeviceId: best.peer.deviceId)
257
+ }
258
+ if let fallback = pairedFallback {
259
+ return .offload(baseUrl: fallback.baseUrl, peerDeviceId: fallback.peerDeviceId)
260
+ }
261
+ return .noCapableDevice(
262
+ json: noCapableDeviceError(localCapability: 0, required: threshold)
263
+ )
264
+ }
265
+
266
+ // header == "prefer" (default)
267
+ if let best, best.score >= threshold {
268
+ return .offload(baseUrl: best.peer.baseUrl, peerDeviceId: best.peer.deviceId)
269
+ }
270
+ // No capability-match candidate. If we're offload-only AND
271
+ // have a paired peer, route to it; the alternative is a 503
272
+ // even though offload is fully wired.
273
+ if backendBaseUrl == nil, let fallback = pairedFallback {
274
+ return .offload(baseUrl: fallback.baseUrl, peerDeviceId: fallback.peerDeviceId)
275
+ }
276
+ if backendBaseUrl != nil { return .local }
277
+ return .noCapableDevice(
278
+ json: noCapableDeviceError(localCapability: 0, required: threshold)
279
+ )
280
+ }
281
+
282
+ public struct RankedPeer: Sendable, Equatable {
283
+ public let peer: MDNSPeer
284
+ public let score: Double
285
+ public let hasModel: Bool
286
+ }
287
+
288
+ public func pickBestPeer(peers: [MDNSPeer], modelId: String) -> RankedPeer? {
289
+ let ranked = peers.compactMap { p -> RankedPeer? in
290
+ let score = p.capability[modelId] ?? 0
291
+ if score <= 0 { return nil }
292
+ return RankedPeer(peer: p, score: score, hasModel: p.loadedModels.contains(modelId))
293
+ }
294
+ .sorted { lhs, rhs in
295
+ if lhs.hasModel != rhs.hasModel { return lhs.hasModel }
296
+ return lhs.score > rhs.score
297
+ }
298
+ return ranked.first
299
+ }
300
+
301
+ /* ================================================================== *
302
+ * Forwarding (URLSession streaming on the upstream leg, Hummingbird *
303
+ * AsyncStream on the response leg) *
304
+ * ================================================================== */
305
+
306
+ private func forwardToLocal(
307
+ method: HTTPRequest.Method,
308
+ path: String,
309
+ body: Data,
310
+ headers: HTTPFields
311
+ ) async -> Response {
312
+ guard let backend = backendBaseUrl else {
313
+ return jsonResponse(status: .serviceUnavailable, body: noLocalBackendError())
314
+ }
315
+ let target = "\(stripTrailing(backend, suffix: "/"))\(path)"
316
+ return await forward(target: target, method: method, body: body, headers: headers,
317
+ signRequest: false, peerDeviceId: nil)
318
+ }
319
+
320
+ private func forwardToPeer(
321
+ baseUrl: String,
322
+ peerDeviceId: String,
323
+ method: HTTPRequest.Method,
324
+ path: String,
325
+ body: Data,
326
+ headers: HTTPFields
327
+ ) async -> Response {
328
+ // The incoming `path` always carries the `/v1/...` prefix the
329
+ // OpenAI client supplied (e.g. `/v1/chat/completions`).
330
+ // Discovered peer baseUrls also carry `/v1` (NWBrowserDiscovery
331
+ // synthesises them as `<scheme>://<host>:<port>/v1`). Naive
332
+ // concatenation produces `…/v1/v1/chat/completions`, which the
333
+ // peer 404s. Strip a trailing `/v1` from the baseUrl before
334
+ // appending the path so we always end up with a single `/v1`.
335
+ var normalisedBase = stripTrailing(baseUrl, suffix: "/")
336
+ if normalisedBase.hasSuffix("/v1") {
337
+ normalisedBase = String(normalisedBase.dropLast("/v1".count))
338
+ }
339
+ let normalizedPath = path.hasPrefix("/v1")
340
+ ? path
341
+ : "/v1" + (path.hasPrefix("/") ? path : "/" + path)
342
+ let target = "\(normalisedBase)\(normalizedPath)"
343
+ return await forward(target: target, method: method, body: body, headers: headers,
344
+ signRequest: true, peerDeviceId: peerDeviceId)
345
+ }
346
+
347
+ private func forward(
348
+ target: String,
349
+ method: HTTPRequest.Method,
350
+ body: Data,
351
+ headers: HTTPFields,
352
+ signRequest: Bool,
353
+ peerDeviceId: String?
354
+ ) async -> Response {
355
+ guard let url = URL(string: target) else {
356
+ return jsonResponse(status: .badGateway,
357
+ body: #"{"error":{"type":"proxy_error","code":502,"message":"invalid forward target"}}"#)
358
+ }
359
+ var urlRequest = URLRequest(url: url)
360
+ urlRequest.httpMethod = method.rawValue
361
+ urlRequest.httpBody = body.isEmpty ? nil : body
362
+
363
+ for f in headers {
364
+ let nameStr = f.name.canonicalName
365
+ let lk = nameStr.lowercased()
366
+ if hopByHop.contains(lk) || lk == "host" || lk == "content-length" { continue }
367
+ urlRequest.setValue(f.value, forHTTPHeaderField: nameStr)
368
+ }
369
+ urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
370
+
371
+ if signRequest, let peerDeviceId, let policy = pairingPolicy {
372
+ if let pairing = await policy.getActive(peerDeviceId: peerDeviceId) {
373
+ let nonce = PairingHandshake.generateNonce()
374
+ // v3.2.1 — route through the canonical PairingHandshake
375
+ // helpers instead of the local `signCanonical`. The
376
+ // local one had THREE protocol bugs vs the TS Hub's
377
+ // verifier (Hub returned 401 every time):
378
+ // - canonical msg order: TS uses
379
+ // `nonce\nMETHOD\npath\nsha256hex(body)`;
380
+ // local used `METHOD\npath\nnonce\nbody-bytes`.
381
+ // - pairingKey encoding: TS decodes base64-url;
382
+ // local used raw UTF-8 bytes.
383
+ // - signature encoding: TS produces base64-url;
384
+ // local emitted hex.
385
+ // PairingHandshake.composeSignedMessage + signHmac
386
+ // already match the TS reference byte-for-byte;
387
+ // they were just unused by the proxy.
388
+ let bodyString = body.isEmpty ? nil : String(data: body, encoding: .utf8) ?? ""
389
+ let canonical = PairingHandshake.composeSignedMessage(
390
+ nonce: nonce,
391
+ method: method.rawValue,
392
+ path: url.path,
393
+ body: bodyString
394
+ )
395
+ if let signature = try? PairingHandshake.signHmac(
396
+ pairingKey: pairing.pairingKey,
397
+ message: canonical
398
+ ) {
399
+ urlRequest.setValue(selfDeviceId, forHTTPHeaderField: "X-DVAI-Peer-Device-Id")
400
+ urlRequest.setValue(appId, forHTTPHeaderField: "X-DVAI-App-Id")
401
+ urlRequest.setValue(nonce, forHTTPHeaderField: "X-DVAI-Nonce")
402
+ urlRequest.setValue(signature, forHTTPHeaderField: "X-DVAI-Signature")
403
+ urlRequest.setValue("1", forHTTPHeaderField: "X-DVAI-Forwarded")
404
+ }
405
+ }
406
+ }
407
+
408
+ do {
409
+ let (asyncBytes, response) = try await session.bytes(for: urlRequest)
410
+ guard let http = response as? HTTPURLResponse else {
411
+ return jsonResponse(status: .badGateway,
412
+ body: #"{"error":{"type":"peer_unreachable","code":502,"message":"non-HTTP response from upstream"}}"#)
413
+ }
414
+
415
+ // Build response headers, dropping hop-by-hop + Content-Length.
416
+ var outHeaders = HTTPFields()
417
+ for (key, value) in http.allHeaderFields {
418
+ let k = "\(key)"
419
+ let lk = k.lowercased()
420
+ if hopByHop.contains(lk) || lk == "content-length" { continue }
421
+ if let nameKey = HTTPField.Name(k) {
422
+ outHeaders.append(HTTPField(name: nameKey, value: "\(value)"))
423
+ }
424
+ }
425
+ // Default content-type to JSON if upstream omitted.
426
+ if outHeaders[.contentType] == nil {
427
+ outHeaders.append(HTTPField(name: .contentType, value: "application/json"))
428
+ }
429
+
430
+ // Stream the upstream body back via ResponseBody.withTrailingHeaders
431
+ // closure. asyncBytes yields UInt8 chunks; we buffer per-line and
432
+ // emit ByteBuffers downstream so the consumer sees incremental
433
+ // tokens for SSE.
434
+ let status = HTTPResponse.Status(code: http.statusCode)
435
+ return Response(
436
+ status: status,
437
+ headers: outHeaders,
438
+ body: ResponseBody { writer in
439
+ var chunkBuf: [UInt8] = []
440
+ chunkBuf.reserveCapacity(8192)
441
+ for try await byte in asyncBytes {
442
+ chunkBuf.append(byte)
443
+ // Flush on newline or every 8 KB so SSE chunks land
444
+ // promptly without per-byte writes.
445
+ if byte == 0x0A || chunkBuf.count >= 8192 {
446
+ try await writer.write(ByteBuffer(bytes: chunkBuf))
447
+ chunkBuf.removeAll(keepingCapacity: true)
448
+ }
449
+ }
450
+ if !chunkBuf.isEmpty {
451
+ try await writer.write(ByteBuffer(bytes: chunkBuf))
452
+ }
453
+ try await writer.finish(nil)
454
+ }
455
+ )
456
+ } catch {
457
+ return jsonResponse(status: .badGateway,
458
+ body: #"{"error":{"type":"peer_unreachable","code":502,"message":"\#(escapeJson(error.localizedDescription))"}}"#)
459
+ }
460
+ }
461
+
462
+ private func collectBody(_ body: RequestBody) async throws -> Data {
463
+ var out = Data()
464
+ for try await buffer in body {
465
+ out.append(contentsOf: buffer.readableBytesView)
466
+ if out.count > MAX_REQUEST_BYTES {
467
+ throw NSError(domain: "OffloadProxy", code: 413,
468
+ userInfo: [NSLocalizedDescriptionKey: "request body exceeds \(MAX_REQUEST_BYTES) bytes"])
469
+ }
470
+ }
471
+ return out
472
+ }
473
+
474
+ private func readModelId(from body: Data) -> String? {
475
+ guard !body.isEmpty,
476
+ let any = try? JSONSerialization.jsonObject(with: body),
477
+ let dict = any as? [String: Any] else {
478
+ return nil
479
+ }
480
+ return dict["model"] as? String
481
+ }
482
+
483
+ private nonisolated func noLocalBackendError() -> String {
484
+ #"{"error":{"type":"no_local_backend","code":503,"message":"DVAI is in offload-only mode and no peer is available."}}"#
485
+ }
486
+
487
+ private nonisolated func noCapableDeviceError(localCapability: Double, required: Double) -> String {
488
+ #"{"error":{"type":"no_capable_device","code":503,"message":"No device with capability >= \#(required) tok/s available.","localCapability":\#(localCapability),"requiredAtLeast":\#(required)}}"#
489
+ }
490
+
491
+ private func handleHandshakeRequest(method: HTTPRequest.Method, body: Data) async -> Response {
492
+ guard method == .post else {
493
+ return jsonResponse(
494
+ status: .methodNotAllowed,
495
+ body: #"{"error":{"type":"method_not_allowed","message":"POST required"}}"#
496
+ )
497
+ }
498
+ guard let policy = pairingPolicy else {
499
+ return jsonResponse(
500
+ status: .serviceUnavailable,
501
+ body: #"{"error":{"type":"pairing_disabled","message":"pairing not configured"}}"#
502
+ )
503
+ }
504
+
505
+ guard let json = (try? JSONSerialization.jsonObject(with: body, options: [])) as? [String: Any] else {
506
+ return jsonResponse(
507
+ status: .badRequest,
508
+ body: #"{"error":{"type":"malformed_handshake","message":"body must be a JSON object"}}"#
509
+ )
510
+ }
511
+ guard let peerDeviceId = json["peerDeviceId"] as? String, !peerDeviceId.isEmpty,
512
+ let peerDeviceName = json["peerDeviceName"] as? String, !peerDeviceName.isEmpty else {
513
+ return jsonResponse(
514
+ status: .badRequest,
515
+ body: #"{"error":{"type":"malformed_handshake","message":"missing peerDeviceId / peerDeviceName"}}"#
516
+ )
517
+ }
518
+ let viaRaw = (json["via"] as? String) ?? "lan-handshake"
519
+ let via = Pairing.Via(rawValue: viaRaw) ?? .lanHandshake
520
+
521
+ do {
522
+ let pairing = try await policy.approveOrFetch(
523
+ peerDeviceId: peerDeviceId,
524
+ peerDeviceName: peerDeviceName,
525
+ via: via
526
+ )
527
+ let payload: [String: Any] = [
528
+ "paired": true,
529
+ "pairedAt": pairing.pairedAt,
530
+ "via": pairing.via.rawValue,
531
+ "pairingKey": pairing.pairingKey,
532
+ "peerDeviceId": pairing.peerDeviceId,
533
+ ]
534
+ let bodyData = (try? JSONSerialization.data(withJSONObject: payload, options: [])) ?? Data()
535
+ return jsonResponse(
536
+ status: .ok,
537
+ body: String(data: bodyData, encoding: .utf8) ?? "{}"
538
+ )
539
+ } catch {
540
+ return jsonResponse(
541
+ status: .forbidden,
542
+ body: #"{"error":{"type":"pairing_denied","message":"\#(escapeJson(error.localizedDescription))"}}"#
543
+ )
544
+ }
545
+ }
546
+
547
+ private nonisolated func jsonResponse(status: HTTPResponse.Status, body: String) -> Response {
548
+ var headers = HTTPFields()
549
+ headers.append(HTTPField(name: .contentType, value: "application/json"))
550
+ return Response(status: status, headers: headers, body: .init(byteBuffer: ByteBuffer(string: body)))
551
+ }
552
+
553
+ private nonisolated func stripTrailing(_ s: String, suffix: String) -> String {
554
+ s.hasSuffix(suffix) ? String(s.dropLast(suffix.count)) : s
555
+ }
556
+
557
+ private nonisolated func escapeJson(_ s: String) -> String {
558
+ s.replacingOccurrences(of: "\\", with: "\\\\")
559
+ .replacingOccurrences(of: "\"", with: "\\\"")
560
+ .replacingOccurrences(of: "\n", with: "\\n")
561
+ .replacingOccurrences(of: "\r", with: "\\r")
562
+ }
563
+
564
+ private let hopByHop: Set<String> = [
565
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
566
+ "te", "trailers", "transfer-encoding", "upgrade", "host",
567
+ ]
568
+ }
569
+
570
+ private let MAX_REQUEST_BYTES = 32 * 1024 * 1024
571
+ #else
572
+ public actor OffloadProxy {
573
+ public let backendBaseUrl: String?
574
+ public let offloadConfig: OffloadConfig
575
+ public let pairingPolicy: PairingPolicy?
576
+ public let peerProvider: @Sendable () async -> [MDNSPeer]
577
+ public let appId: String
578
+ public let selfDeviceId: String
579
+
580
+ public init(
581
+ backendBaseUrl: String?,
582
+ offloadConfig: OffloadConfig,
583
+ pairingPolicy: PairingPolicy?,
584
+ peerProvider: @escaping @Sendable () async -> [MDNSPeer],
585
+ appId: String,
586
+ selfDeviceId: String
587
+ ) {
588
+ self.backendBaseUrl = backendBaseUrl
589
+ self.offloadConfig = offloadConfig
590
+ self.pairingPolicy = pairingPolicy
591
+ self.peerProvider = peerProvider
592
+ self.appId = appId
593
+ self.selfDeviceId = selfDeviceId
594
+ }
595
+
596
+ public func start(basePort: Int, maxAttempts: Int = 16, host: String = "127.0.0.1") async throws -> Int {
597
+ throw NSError(domain: "OffloadProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "OffloadProxy is not supported in CocoaPods builds. Use SwiftPM for offloading support."])
598
+ }
599
+
600
+ public func stop() async {}
601
+ public func baseUrl() -> String? { nil }
602
+ public func currentPort() -> Int { -1 }
603
+ }
604
+ #endif