@dvai-bridge/android 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/LICENSE +51 -0
- package/README.md +199 -0
- package/android/build.gradle +165 -0
- package/android/gradle.properties +5 -0
- package/android/settings.gradle +1 -0
- package/android/src/androidTest/java/co/deepvoiceai/bridge/RealModelIntegrationTest.kt +162 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BackendKind.kt +21 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BackendSelector.kt +28 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BoundServer.kt +39 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridge.kt +642 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeConfig.kt +119 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeError.kt +51 -0
- package/android/src/main/java/co/deepvoiceai/bridge/OffloadProxy.kt +574 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ProgressBroadcaster.kt +55 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ProgressEvent.kt +25 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ReactiveState.kt +60 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/Audience.kt +134 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/Discovery.kt +146 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseTypes.kt +158 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseValidator.kt +400 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/PublicKeys.kt +91 -0
- package/android/src/test/java/co/deepvoiceai/bridge/BackendSelectorTest.kt +76 -0
- package/android/src/test/java/co/deepvoiceai/bridge/CapabilityPrecheckTest.kt +117 -0
- package/android/src/test/java/co/deepvoiceai/bridge/DVAIBridgeAPIShapeTest.kt +86 -0
- package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyDecisionTest.kt +144 -0
- package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyForwardingTest.kt +327 -0
- package/android/src/test/java/co/deepvoiceai/bridge/ProgressBroadcasterTest.kt +56 -0
- package/android/src/test/java/co/deepvoiceai/bridge/license/LicenseValidatorTest.kt +539 -0
- package/package.json +19 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import co.deepvoiceai.bridge.shared.core.discovery.NsdDiscovery
|
|
4
|
+
import co.deepvoiceai.bridge.shared.core.discovery.Peer
|
|
5
|
+
import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
|
|
6
|
+
import co.deepvoiceai.bridge.shared.core.pairing.PairingPolicy
|
|
7
|
+
import io.ktor.client.HttpClient
|
|
8
|
+
import io.ktor.client.engine.cio.CIO as ClientCIO
|
|
9
|
+
import io.ktor.client.plugins.HttpTimeout
|
|
10
|
+
import io.ktor.client.request.headers
|
|
11
|
+
import io.ktor.client.request.prepareRequest
|
|
12
|
+
import io.ktor.client.request.setBody
|
|
13
|
+
import io.ktor.client.statement.HttpResponse
|
|
14
|
+
import io.ktor.client.statement.bodyAsChannel
|
|
15
|
+
import io.ktor.http.ContentType
|
|
16
|
+
import io.ktor.http.HttpHeaders
|
|
17
|
+
import io.ktor.http.HttpMethod
|
|
18
|
+
import io.ktor.http.HttpStatusCode
|
|
19
|
+
import io.ktor.http.contentType
|
|
20
|
+
import io.ktor.server.application.call
|
|
21
|
+
import io.ktor.server.application.install
|
|
22
|
+
import io.ktor.server.cio.CIO as ServerCIO
|
|
23
|
+
import io.ktor.server.engine.ApplicationEngine
|
|
24
|
+
import io.ktor.server.engine.embeddedServer
|
|
25
|
+
import io.ktor.server.plugins.statuspages.StatusPages
|
|
26
|
+
import io.ktor.server.request.httpMethod
|
|
27
|
+
import io.ktor.server.request.receiveChannel
|
|
28
|
+
import io.ktor.server.request.uri
|
|
29
|
+
import io.ktor.server.response.respondBytes
|
|
30
|
+
import io.ktor.server.response.respondBytesWriter
|
|
31
|
+
import io.ktor.server.response.respondText
|
|
32
|
+
import io.ktor.server.routing.route
|
|
33
|
+
import io.ktor.server.routing.routing
|
|
34
|
+
import io.ktor.utils.io.ByteReadChannel
|
|
35
|
+
import io.ktor.utils.io.ByteWriteChannel
|
|
36
|
+
import io.ktor.utils.io.copyAndClose
|
|
37
|
+
import kotlinx.serialization.json.Json
|
|
38
|
+
import kotlinx.serialization.json.JsonObject
|
|
39
|
+
import kotlinx.serialization.json.boolean
|
|
40
|
+
import kotlinx.serialization.json.contentOrNull
|
|
41
|
+
import kotlinx.serialization.json.jsonPrimitive
|
|
42
|
+
import java.net.BindException
|
|
43
|
+
import java.security.MessageDigest
|
|
44
|
+
import java.security.SecureRandom
|
|
45
|
+
import javax.crypto.Mac
|
|
46
|
+
import javax.crypto.spec.SecretKeySpec
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* v3.2 — Pre-routing HTTP proxy for the Android SDK.
|
|
50
|
+
*
|
|
51
|
+
* Architecture:
|
|
52
|
+
*
|
|
53
|
+
* consumer app -> http://127.0.0.1:proxyPort/v1/...
|
|
54
|
+
* |
|
|
55
|
+
* +-- if local-decision -> forward to
|
|
56
|
+
* | http://127.0.0.1:backendPort/v1/...
|
|
57
|
+
* | (the native backend's internal port)
|
|
58
|
+
* |
|
|
59
|
+
* +-- if offload-decision -> HMAC-sign + forward to
|
|
60
|
+
* http://peer-ip:peer-port/v1/...
|
|
61
|
+
* (X-DVAI-Peer-Device-Id, X-DVAI-App-Id,
|
|
62
|
+
* X-DVAI-Nonce, X-DVAI-Signature)
|
|
63
|
+
*
|
|
64
|
+
* SSE-aware: when the consumer's request body has `stream: true` (or the
|
|
65
|
+
* upstream replies with `Content-Type: text/event-stream`), bytes pipe
|
|
66
|
+
* through unmodified so the consumer's OpenAI client gets incremental
|
|
67
|
+
* tokens.
|
|
68
|
+
*
|
|
69
|
+
* Lifecycle is owned by [DVAIBridge]. Don't construct from consumer code.
|
|
70
|
+
*
|
|
71
|
+
* @param backendBaseUrl The native backend's internal loopback URL (e.g.
|
|
72
|
+
* `http://127.0.0.1:38884`). Null when the SDK is in offload-only mode
|
|
73
|
+
* (no backend; every request must forward to a peer or 503).
|
|
74
|
+
* @param offloadConfig The active OffloadConfig — read per-request so
|
|
75
|
+
* `enabled` / `minLocalCapability` flips at runtime are honored.
|
|
76
|
+
* @param pairingPolicy Source of pairing keys for HMAC-signing.
|
|
77
|
+
* @param discovery Source of the live peer list per request.
|
|
78
|
+
* @param appId This consumer app's identifier — surfaced as
|
|
79
|
+
* X-DVAI-App-Id on forwarded requests.
|
|
80
|
+
* @param selfDeviceId This device's stable identifier — surfaced as
|
|
81
|
+
* X-DVAI-Peer-Device-Id so peers verify the HMAC against the right key.
|
|
82
|
+
*/
|
|
83
|
+
class OffloadProxy(
|
|
84
|
+
private val backendBaseUrl: String?,
|
|
85
|
+
private val offloadConfig: OffloadConfig,
|
|
86
|
+
private val pairingPolicy: PairingPolicy?,
|
|
87
|
+
/** Snapshot of the live peer list. Defaults to [discovery]?.peers(). */
|
|
88
|
+
private val peerProvider: () -> List<Peer>,
|
|
89
|
+
private val appId: String,
|
|
90
|
+
private val selfDeviceId: String,
|
|
91
|
+
) {
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convenience constructor used by [DVAIBridge] — wraps an
|
|
95
|
+
* NsdDiscovery's peer list as the provider. Tests can use the
|
|
96
|
+
* primary constructor to inject a fake peer source.
|
|
97
|
+
*/
|
|
98
|
+
constructor(
|
|
99
|
+
backendBaseUrl: String?,
|
|
100
|
+
offloadConfig: OffloadConfig,
|
|
101
|
+
pairingPolicy: PairingPolicy?,
|
|
102
|
+
discovery: NsdDiscovery?,
|
|
103
|
+
appId: String,
|
|
104
|
+
selfDeviceId: String,
|
|
105
|
+
) : this(
|
|
106
|
+
backendBaseUrl = backendBaseUrl,
|
|
107
|
+
offloadConfig = offloadConfig,
|
|
108
|
+
pairingPolicy = pairingPolicy,
|
|
109
|
+
peerProvider = { discovery?.peers() ?: emptyList() },
|
|
110
|
+
appId = appId,
|
|
111
|
+
selfDeviceId = selfDeviceId,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
|
115
|
+
|
|
116
|
+
private val client: HttpClient = HttpClient(ClientCIO) {
|
|
117
|
+
install(HttpTimeout) {
|
|
118
|
+
requestTimeoutMillis = REQUEST_TIMEOUT_MS
|
|
119
|
+
connectTimeoutMillis = 10_000
|
|
120
|
+
socketTimeoutMillis = REQUEST_TIMEOUT_MS
|
|
121
|
+
}
|
|
122
|
+
expectSuccess = false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Volatile private var server: ApplicationEngine? = null
|
|
126
|
+
@Volatile private var boundPort: Int = -1
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Bind the proxy. Tries [basePort], [basePort]+1, ... up to
|
|
130
|
+
* [maxAttempts] times. Returns the bound port.
|
|
131
|
+
*/
|
|
132
|
+
fun start(basePort: Int = DEFAULT_BASE_PORT, maxAttempts: Int = 16): Int {
|
|
133
|
+
require(server == null) { "OffloadProxy already started" }
|
|
134
|
+
|
|
135
|
+
var lastError: Throwable? = null
|
|
136
|
+
for (attempt in 0 until maxAttempts) {
|
|
137
|
+
val port = basePort + attempt
|
|
138
|
+
try {
|
|
139
|
+
val engine = embeddedServer(ServerCIO, port = port, host = "127.0.0.1") {
|
|
140
|
+
install(StatusPages) {
|
|
141
|
+
exception<Throwable> { call, cause ->
|
|
142
|
+
call.respondText(
|
|
143
|
+
"""{"error":{"type":"proxy_error","code":500,"message":"${escapeJson(cause.message ?: cause::class.qualifiedName ?: "internal error")}"}}""",
|
|
144
|
+
ContentType.Application.Json,
|
|
145
|
+
HttpStatusCode.InternalServerError,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
routing {
|
|
150
|
+
// Catch-all — the route logic is inside the handler.
|
|
151
|
+
// `{...}` matches any path including slashes; the
|
|
152
|
+
// bare `handle { }` form without a path pattern
|
|
153
|
+
// doesn't actually wire a handler in Ktor 2.3.
|
|
154
|
+
route("{...}") {
|
|
155
|
+
handle {
|
|
156
|
+
handleRequest(call)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
engine.start(wait = false)
|
|
162
|
+
server = engine
|
|
163
|
+
boundPort = port
|
|
164
|
+
return port
|
|
165
|
+
} catch (e: BindException) {
|
|
166
|
+
lastError = e
|
|
167
|
+
} catch (e: Throwable) {
|
|
168
|
+
lastError = e
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw DVAIBridgeError.BackendError(
|
|
172
|
+
lastError ?: RuntimeException(
|
|
173
|
+
"OffloadProxy: failed to bind any port in $basePort..${basePort + maxAttempts - 1}",
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Stop the proxy + close the HTTP client. Idempotent. */
|
|
179
|
+
fun stop() {
|
|
180
|
+
try {
|
|
181
|
+
server?.stop(gracePeriodMillis = 500, timeoutMillis = 2_000)
|
|
182
|
+
} catch (_: Throwable) {
|
|
183
|
+
}
|
|
184
|
+
server = null
|
|
185
|
+
boundPort = -1
|
|
186
|
+
try {
|
|
187
|
+
client.close()
|
|
188
|
+
} catch (_: Throwable) {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Public bind URL once started; null before start(). */
|
|
193
|
+
fun baseUrl(): String? = if (boundPort > 0) "http://127.0.0.1:$boundPort" else null
|
|
194
|
+
|
|
195
|
+
/* ================================================================== *
|
|
196
|
+
* Request handler *
|
|
197
|
+
* ================================================================== */
|
|
198
|
+
|
|
199
|
+
private suspend fun handleRequest(call: io.ktor.server.application.ApplicationCall) {
|
|
200
|
+
val incomingUri = call.request.uri
|
|
201
|
+
val method = call.request.httpMethod
|
|
202
|
+
val incomingHeaders = call.request.headers.toLowerCaseMap()
|
|
203
|
+
val bodyBytes: ByteArray = call.receiveChannel().toByteArray(MAX_REQUEST_BYTES)
|
|
204
|
+
|
|
205
|
+
val decision = decideRoute(incomingUri, bodyBytes, incomingHeaders)
|
|
206
|
+
when (decision) {
|
|
207
|
+
is RouteDecision.Local ->
|
|
208
|
+
forwardToLocal(call, method, incomingUri, bodyBytes, incomingHeaders)
|
|
209
|
+
is RouteDecision.Offload ->
|
|
210
|
+
forwardToPeer(call, decision, method, incomingUri, bodyBytes, incomingHeaders)
|
|
211
|
+
is RouteDecision.NoCapableDevice -> {
|
|
212
|
+
call.respondText(
|
|
213
|
+
decision.body,
|
|
214
|
+
ContentType.Application.Json,
|
|
215
|
+
HttpStatusCode.ServiceUnavailable,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ================================================================== *
|
|
222
|
+
* Decision logic *
|
|
223
|
+
* ================================================================== */
|
|
224
|
+
|
|
225
|
+
internal sealed class RouteDecision {
|
|
226
|
+
data object Local : RouteDecision()
|
|
227
|
+
data class Offload(val baseUrl: String, val peerDeviceId: String) : RouteDecision()
|
|
228
|
+
data class NoCapableDevice(val body: String) : RouteDecision()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
internal fun decideRoute(
|
|
232
|
+
path: String,
|
|
233
|
+
body: ByteArray,
|
|
234
|
+
headers: Map<String, String>,
|
|
235
|
+
): RouteDecision {
|
|
236
|
+
val isChatCompletion =
|
|
237
|
+
path.endsWith("/chat/completions") || path.endsWith("/v1/chat/completions")
|
|
238
|
+
|
|
239
|
+
if (!isChatCompletion) {
|
|
240
|
+
return if (backendBaseUrl != null) RouteDecision.Local
|
|
241
|
+
else RouteDecision.NoCapableDevice(noLocalBackendError())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!offloadConfig.enabled) {
|
|
245
|
+
return if (backendBaseUrl != null) RouteDecision.Local
|
|
246
|
+
else RouteDecision.NoCapableDevice(noLocalBackendError())
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
val offloadHeader = headers["x-dvai-offload"]?.lowercase() ?: "prefer"
|
|
250
|
+
if (offloadHeader == "never") {
|
|
251
|
+
return if (backendBaseUrl != null) RouteDecision.Local
|
|
252
|
+
else RouteDecision.NoCapableDevice(noLocalBackendError())
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
val modelId = readModelId(body) ?: ""
|
|
256
|
+
val peers = peerProvider()
|
|
257
|
+
|
|
258
|
+
val best = pickBestPeer(peers, modelId)
|
|
259
|
+
val threshold = offloadConfig.minLocalCapability
|
|
260
|
+
|
|
261
|
+
if (offloadHeader == "require") {
|
|
262
|
+
if (best != null) {
|
|
263
|
+
return RouteDecision.Offload(best.peer.baseUrl, best.peer.deviceId)
|
|
264
|
+
}
|
|
265
|
+
return RouteDecision.NoCapableDevice(
|
|
266
|
+
noCapableDeviceError(localCapability = 0.0, required = threshold),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// header == "prefer" (default).
|
|
271
|
+
if (best != null && best.score >= threshold) {
|
|
272
|
+
return RouteDecision.Offload(best.peer.baseUrl, best.peer.deviceId)
|
|
273
|
+
}
|
|
274
|
+
if (backendBaseUrl != null) return RouteDecision.Local
|
|
275
|
+
return RouteDecision.NoCapableDevice(
|
|
276
|
+
noCapableDeviceError(localCapability = 0.0, required = threshold),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
internal data class RankedPeer(val peer: Peer, val score: Double, val hasModel: Boolean)
|
|
281
|
+
|
|
282
|
+
internal fun pickBestPeer(peers: List<Peer>, modelId: String): RankedPeer? {
|
|
283
|
+
val ranked = peers
|
|
284
|
+
.map { p ->
|
|
285
|
+
val score = p.capability[modelId] ?: 0.0
|
|
286
|
+
RankedPeer(p, score, p.loadedModels.contains(modelId))
|
|
287
|
+
}
|
|
288
|
+
.filter { it.score > 0.0 }
|
|
289
|
+
.sortedWith(
|
|
290
|
+
compareByDescending<RankedPeer> { it.hasModel }
|
|
291
|
+
.thenByDescending { it.score },
|
|
292
|
+
)
|
|
293
|
+
return ranked.firstOrNull()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ================================================================== *
|
|
297
|
+
* Forwarding *
|
|
298
|
+
* ================================================================== */
|
|
299
|
+
|
|
300
|
+
private suspend fun forwardToLocal(
|
|
301
|
+
call: io.ktor.server.application.ApplicationCall,
|
|
302
|
+
method: HttpMethod,
|
|
303
|
+
path: String,
|
|
304
|
+
body: ByteArray,
|
|
305
|
+
headers: Map<String, String>,
|
|
306
|
+
) {
|
|
307
|
+
val target = "${backendBaseUrl!!.trimEnd('/')}$path"
|
|
308
|
+
forwardRequest(call, method, target, body, headers, signRequest = false, peerDeviceId = null)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private suspend fun forwardToPeer(
|
|
312
|
+
call: io.ktor.server.application.ApplicationCall,
|
|
313
|
+
decision: RouteDecision.Offload,
|
|
314
|
+
method: HttpMethod,
|
|
315
|
+
path: String,
|
|
316
|
+
body: ByteArray,
|
|
317
|
+
headers: Map<String, String>,
|
|
318
|
+
) {
|
|
319
|
+
// v3.2.1 — peer baseUrls always carry `/v1` (synthesised by
|
|
320
|
+
// both NsdDiscovery on Android + NWBrowserDiscovery on iOS as
|
|
321
|
+
// `<scheme>://<host>:<port>/v1`). Strip it before appending
|
|
322
|
+
// the consumer's path (which itself begins with `/v1`),
|
|
323
|
+
// otherwise we end up with `…/v1/v1/chat/completions` and
|
|
324
|
+
// the peer 404s. Same fix iOS got in commit 30d0be2.
|
|
325
|
+
val baseStripped = decision.baseUrl.trimEnd('/').removeSuffix("/v1")
|
|
326
|
+
val normalizedPath = if (path.startsWith("/v1")) path else "/v1${path.removePrefix("/")}"
|
|
327
|
+
val target = "$baseStripped$normalizedPath"
|
|
328
|
+
forwardRequest(call, method, target, body, headers, signRequest = true, peerDeviceId = decision.peerDeviceId)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private suspend fun forwardRequest(
|
|
332
|
+
call: io.ktor.server.application.ApplicationCall,
|
|
333
|
+
method: HttpMethod,
|
|
334
|
+
target: String,
|
|
335
|
+
body: ByteArray,
|
|
336
|
+
headers: Map<String, String>,
|
|
337
|
+
signRequest: Boolean,
|
|
338
|
+
peerDeviceId: String?,
|
|
339
|
+
) {
|
|
340
|
+
val streamRequested = isStreamRequested(body, headers)
|
|
341
|
+
|
|
342
|
+
client.prepareRequest(target) {
|
|
343
|
+
this.method = method
|
|
344
|
+
headers {
|
|
345
|
+
for ((k, v) in headers) {
|
|
346
|
+
if (k in HOP_BY_HOP) continue
|
|
347
|
+
if (k == "host" || k == "content-length") continue
|
|
348
|
+
append(k, v)
|
|
349
|
+
}
|
|
350
|
+
if (signRequest && peerDeviceId != null && pairingPolicy != null) {
|
|
351
|
+
val pairing = pairingPolicy.getActive(peerDeviceId)
|
|
352
|
+
if (pairing != null) {
|
|
353
|
+
val nonce = newNonce()
|
|
354
|
+
val signature = signCanonical(
|
|
355
|
+
method = method.value,
|
|
356
|
+
path = pathOnly(target),
|
|
357
|
+
body = body,
|
|
358
|
+
nonce = nonce,
|
|
359
|
+
pairingKey = pairing.pairingKey,
|
|
360
|
+
)
|
|
361
|
+
append("X-DVAI-Peer-Device-Id", selfDeviceId)
|
|
362
|
+
append("X-DVAI-App-Id", appId)
|
|
363
|
+
append("X-DVAI-Nonce", nonce)
|
|
364
|
+
append("X-DVAI-Signature", signature)
|
|
365
|
+
append("X-DVAI-Forwarded", "1")
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (body.isNotEmpty()) {
|
|
370
|
+
contentType(ContentType.Application.Json)
|
|
371
|
+
setBody(body)
|
|
372
|
+
}
|
|
373
|
+
}.execute { response: HttpResponse ->
|
|
374
|
+
relayResponse(call, response, streamRequested)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private suspend fun relayResponse(
|
|
379
|
+
call: io.ktor.server.application.ApplicationCall,
|
|
380
|
+
response: HttpResponse,
|
|
381
|
+
streamRequested: Boolean,
|
|
382
|
+
) {
|
|
383
|
+
// Echo upstream headers (skipping hop-by-hop + content-length;
|
|
384
|
+
// Ktor recomputes the latter).
|
|
385
|
+
for ((name, values) in response.headers.entries()) {
|
|
386
|
+
val lname = name.lowercase()
|
|
387
|
+
if (lname in HOP_BY_HOP) continue
|
|
388
|
+
if (lname == HttpHeaders.ContentLength.lowercase()) continue
|
|
389
|
+
for (v in values) {
|
|
390
|
+
call.response.headers.append(name, v, safeOnly = false)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
val upstreamCT = response.headers[HttpHeaders.ContentType]
|
|
394
|
+
val isSse = streamRequested ||
|
|
395
|
+
(upstreamCT?.startsWith("text/event-stream", ignoreCase = true) == true)
|
|
396
|
+
|
|
397
|
+
val statusCode = HttpStatusCode.fromValue(response.status.value)
|
|
398
|
+
|
|
399
|
+
if (isSse) {
|
|
400
|
+
call.respondBytesWriter(
|
|
401
|
+
contentType = ContentType.parse(upstreamCT ?: "text/event-stream"),
|
|
402
|
+
status = statusCode,
|
|
403
|
+
) {
|
|
404
|
+
response.bodyAsChannel().copyAndClose(this)
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
val bytes = response.bodyAsChannel().toByteArray(MAX_RESPONSE_BYTES)
|
|
408
|
+
call.respondBytes(
|
|
409
|
+
bytes,
|
|
410
|
+
ContentType.parse(upstreamCT ?: "application/json"),
|
|
411
|
+
statusCode,
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ================================================================== *
|
|
417
|
+
* HMAC *
|
|
418
|
+
* ================================================================== */
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* v3.2.1 — sign the canonical message that matches the TS Hub's
|
|
422
|
+
* `verifyHmac` byte-for-byte. The earlier implementation had three
|
|
423
|
+
* independent protocol bugs that produced 401 every time:
|
|
424
|
+
* - canonical msg order: TS spec is
|
|
425
|
+
* `nonce\nMETHOD\npath\nsha256hex(body)`;
|
|
426
|
+
* prior local impl used `METHOD\npath\nnonce\nbody-bytes`.
|
|
427
|
+
* - pairingKey encoding: TS decodes base64-url; prior local
|
|
428
|
+
* impl used raw UTF-8 bytes.
|
|
429
|
+
* - signature encoding: TS produces base64-url; prior local
|
|
430
|
+
* impl emitted hex.
|
|
431
|
+
* Fixed inline here to keep mobile parity with iOS commit 5292482.
|
|
432
|
+
*/
|
|
433
|
+
private fun signCanonical(
|
|
434
|
+
method: String,
|
|
435
|
+
path: String,
|
|
436
|
+
body: ByteArray,
|
|
437
|
+
nonce: String,
|
|
438
|
+
pairingKey: String,
|
|
439
|
+
): String {
|
|
440
|
+
// sha256(body) → lowercase hex, OR all-zeros for empty body.
|
|
441
|
+
val bodyHash: String = if (body.isEmpty()) {
|
|
442
|
+
"0".repeat(64)
|
|
443
|
+
} else {
|
|
444
|
+
val md = MessageDigest.getInstance("SHA-256")
|
|
445
|
+
md.digest(body).toHex()
|
|
446
|
+
}
|
|
447
|
+
val canonical = "$nonce\n${method.uppercase()}\n$path\n$bodyHash"
|
|
448
|
+
|
|
449
|
+
val keyBytes = base64UrlDecode(pairingKey)
|
|
450
|
+
val mac = Mac.getInstance("HmacSHA256").apply {
|
|
451
|
+
init(SecretKeySpec(keyBytes, "HmacSHA256"))
|
|
452
|
+
}
|
|
453
|
+
val sigBytes = mac.doFinal(canonical.toByteArray(Charsets.UTF_8))
|
|
454
|
+
return base64UrlEncode(sigBytes)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Generate a fresh 128-bit nonce. Encoded as base64-url to match
|
|
459
|
+
* the TS reference; encoding doesn't actually matter for HMAC
|
|
460
|
+
* verification (both sides see the same string in the
|
|
461
|
+
* `X-DVAI-Nonce` header) but keeping the format consistent with
|
|
462
|
+
* iOS + .NET avoids confusion in audit logs.
|
|
463
|
+
*/
|
|
464
|
+
private fun newNonce(): String {
|
|
465
|
+
val bytes = ByteArray(16)
|
|
466
|
+
SecureRandom().nextBytes(bytes)
|
|
467
|
+
return base64UrlEncode(bytes)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* RFC 4648 §5 base64-url encode without padding. Uses
|
|
472
|
+
* `java.util.Base64` rather than `android.util.Base64` so the
|
|
473
|
+
* helper is reachable from plain-JVM unit tests too — Android's
|
|
474
|
+
* util class isn't present off-device unless Robolectric is
|
|
475
|
+
* shimmed in.
|
|
476
|
+
*/
|
|
477
|
+
private fun base64UrlEncode(bytes: ByteArray): String {
|
|
478
|
+
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** RFC 4648 §5 base64-url decode (accepts padding or no-padding). */
|
|
482
|
+
private fun base64UrlDecode(s: String): ByteArray {
|
|
483
|
+
return java.util.Base64.getUrlDecoder().decode(s)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* ================================================================== *
|
|
487
|
+
* Helpers *
|
|
488
|
+
* ================================================================== */
|
|
489
|
+
|
|
490
|
+
private fun readModelId(body: ByteArray): String? {
|
|
491
|
+
if (body.isEmpty()) return null
|
|
492
|
+
return try {
|
|
493
|
+
val parsed = json.parseToJsonElement(body.decodeToString())
|
|
494
|
+
(parsed as? JsonObject)?.get("model")?.jsonPrimitive?.contentOrNull
|
|
495
|
+
} catch (_: Throwable) {
|
|
496
|
+
null
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private fun isStreamRequested(body: ByteArray, headers: Map<String, String>): Boolean {
|
|
501
|
+
if (headers["accept"]?.contains("text/event-stream", ignoreCase = true) == true) return true
|
|
502
|
+
if (body.isEmpty()) return false
|
|
503
|
+
return try {
|
|
504
|
+
val parsed = json.parseToJsonElement(body.decodeToString())
|
|
505
|
+
(parsed as? JsonObject)?.get("stream")?.jsonPrimitive?.boolean == true
|
|
506
|
+
} catch (_: Throwable) {
|
|
507
|
+
false
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private fun pathOnly(url: String): String =
|
|
512
|
+
try {
|
|
513
|
+
java.net.URI(url).rawPath.takeIf { !it.isNullOrEmpty() } ?: "/"
|
|
514
|
+
} catch (_: Throwable) {
|
|
515
|
+
"/"
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private fun noLocalBackendError(): String =
|
|
519
|
+
"""{"error":{"type":"no_local_backend","code":503,"message":"DVAI is in offload-only mode and no peer is available."}}"""
|
|
520
|
+
|
|
521
|
+
private fun noCapableDeviceError(localCapability: Double, required: Double): String =
|
|
522
|
+
"""{"error":{"type":"no_capable_device","code":503,"message":"No device with capability >= $required tok/s available.","localCapability":$localCapability,"requiredAtLeast":$required}}"""
|
|
523
|
+
|
|
524
|
+
companion object {
|
|
525
|
+
const val DEFAULT_BASE_PORT: Int = 38883
|
|
526
|
+
private const val REQUEST_TIMEOUT_MS: Long = 600_000L // 10 min
|
|
527
|
+
private const val MAX_REQUEST_BYTES: Long = 32L * 1024 * 1024 // 32 MB
|
|
528
|
+
private const val MAX_RESPONSE_BYTES: Long = 64L * 1024 * 1024 // 64 MB
|
|
529
|
+
private val HOP_BY_HOP = setOf(
|
|
530
|
+
"connection", "keep-alive", "proxy-authenticate",
|
|
531
|
+
"proxy-authorization", "te", "trailers",
|
|
532
|
+
"transfer-encoding", "upgrade", "host",
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* -------------------------------------------------------------------------- */
|
|
538
|
+
/* File-private helpers */
|
|
539
|
+
/* -------------------------------------------------------------------------- */
|
|
540
|
+
|
|
541
|
+
private fun io.ktor.http.Headers.toLowerCaseMap(): Map<String, String> {
|
|
542
|
+
val out = HashMap<String, String>()
|
|
543
|
+
for (name in names()) {
|
|
544
|
+
out[name.lowercase()] = get(name).orEmpty()
|
|
545
|
+
}
|
|
546
|
+
return out
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private suspend fun ByteReadChannel.toByteArray(maxBytes: Long): ByteArray {
|
|
550
|
+
val baos = java.io.ByteArrayOutputStream()
|
|
551
|
+
val buf = ByteArray(8192)
|
|
552
|
+
var total: Long = 0
|
|
553
|
+
while (true) {
|
|
554
|
+
val n = readAvailable(buf, 0, buf.size)
|
|
555
|
+
if (n <= 0) break
|
|
556
|
+
total += n
|
|
557
|
+
if (total > maxBytes) {
|
|
558
|
+
throw IllegalStateException("body exceeds $maxBytes bytes")
|
|
559
|
+
}
|
|
560
|
+
baos.write(buf, 0, n)
|
|
561
|
+
}
|
|
562
|
+
return baos.toByteArray()
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private fun ByteArray.toHex(): String {
|
|
566
|
+
val sb = StringBuilder(size * 2)
|
|
567
|
+
for (b in this) {
|
|
568
|
+
sb.append("%02x".format(b))
|
|
569
|
+
}
|
|
570
|
+
return sb.toString()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private fun escapeJson(s: String): String =
|
|
574
|
+
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
4
|
+
import kotlinx.coroutines.flow.SharedFlow
|
|
5
|
+
import kotlinx.coroutines.flow.asSharedFlow
|
|
6
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal: routes [ProgressEvent]s to both subscribers of the [SharedFlow]
|
|
10
|
+
* surface (idiomatic Kotlin) and registered [ProgressListener] callbacks
|
|
11
|
+
* (Java-friendly + parity with the iOS Combine + AsyncStream surfaces).
|
|
12
|
+
*
|
|
13
|
+
* Both surfaces emit the same events in the same order. Listener callbacks
|
|
14
|
+
* are invoked synchronously on the same thread that called [emit] —
|
|
15
|
+
* consumers should hand off to their own dispatcher if they want to update
|
|
16
|
+
* UI from a UI thread.
|
|
17
|
+
*/
|
|
18
|
+
internal class ProgressBroadcaster {
|
|
19
|
+
// Replay buffer of 1 so a late subscriber sees the most recent event.
|
|
20
|
+
// extraBufferCapacity = 16 absorbs short bursts without suspending the
|
|
21
|
+
// emit() caller.
|
|
22
|
+
private val sharedFlow = MutableSharedFlow<ProgressEvent>(replay = 1, extraBufferCapacity = 16)
|
|
23
|
+
val flow: SharedFlow<ProgressEvent> = sharedFlow.asSharedFlow()
|
|
24
|
+
|
|
25
|
+
private val listeners = CopyOnWriteArrayList<ProgressListener>()
|
|
26
|
+
|
|
27
|
+
fun addListener(listener: ProgressListener) {
|
|
28
|
+
listeners.add(listener)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fun removeListener(listener: ProgressListener) {
|
|
32
|
+
listeners.remove(listener)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Emit an event to all subscribers. tryEmit returns false only if the
|
|
37
|
+
* buffer is full; we ignore the failure (event will arrive via the
|
|
38
|
+
* listener path either way).
|
|
39
|
+
*/
|
|
40
|
+
fun emit(event: ProgressEvent) {
|
|
41
|
+
sharedFlow.tryEmit(event)
|
|
42
|
+
for (l in listeners) {
|
|
43
|
+
try {
|
|
44
|
+
l.onProgress(event)
|
|
45
|
+
} catch (_: Throwable) {
|
|
46
|
+
// Don't let a misbehaving listener block other listeners.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** SAM interface for [DVAIBridge.addProgressListener] / [DVAIBridge.removeProgressListener]. */
|
|
53
|
+
fun interface ProgressListener {
|
|
54
|
+
fun onProgress(event: ProgressEvent)
|
|
55
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Progress events emitted while [DVAIBridge.start] / [DVAIBridge.downloadModel]
|
|
5
|
+
* are running. Subscribe via [DVAIBridge.progressFlow] (Kotlin idiomatic) or
|
|
6
|
+
* [DVAIBridge.addProgressListener] (Java-friendly callback shape).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors iOS `ProgressEvent` 1:1.
|
|
9
|
+
*/
|
|
10
|
+
sealed class ProgressEvent {
|
|
11
|
+
/** A long-running operation has started (download or model load). */
|
|
12
|
+
data class Started(val phase: String) : ProgressEvent()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Operation is in progress. [percent] is in `[0.0, 1.0]` when known,
|
|
16
|
+
* negative when indeterminate.
|
|
17
|
+
*/
|
|
18
|
+
data class Progress(val phase: String, val percent: Float, val message: String = "") : ProgressEvent()
|
|
19
|
+
|
|
20
|
+
/** Operation finished successfully. */
|
|
21
|
+
data class Completed(val phase: String) : ProgressEvent()
|
|
22
|
+
|
|
23
|
+
/** Operation failed; the bridge stays in its prior state. */
|
|
24
|
+
data class Failed(val phase: String, val error: DVAIBridgeError) : ProgressEvent()
|
|
25
|
+
}
|