@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.
- package/DVAIBridge.podspec +120 -0
- package/LICENSE +51 -0
- package/Package.swift +104 -0
- package/README.md +199 -0
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
- package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
- package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
- package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
- package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
- package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
- package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
- package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
- package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
- package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
- package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
- package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
- 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
|