@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,86 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertNotNull
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import kotlin.reflect.full.functions
|
|
8
|
+
import kotlin.reflect.full.memberProperties
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reflection-based smoke checks on the public DVAIBridge surface. These
|
|
12
|
+
* don't actually invoke the bridge — they just assert the 8 expected
|
|
13
|
+
* public methods + properties exist with the right shape.
|
|
14
|
+
*
|
|
15
|
+
* The point: prevent accidental rename / removal of public API across
|
|
16
|
+
* cross-platform parity refactors. Compare against
|
|
17
|
+
* `packages/dvai-bridge-ios/ios/Sources/DVAIBridge/DVAIBridge.swift`.
|
|
18
|
+
*/
|
|
19
|
+
class DVAIBridgeAPIShapeTest {
|
|
20
|
+
@Test
|
|
21
|
+
fun `singleton is the DVAIBridge object`() {
|
|
22
|
+
val instance = DVAIBridge
|
|
23
|
+
assertNotNull(instance)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `public methods include start stop status init downloadModel listeners`() {
|
|
28
|
+
val fnNames = DVAIBridge::class.functions.map { it.name }.toSet()
|
|
29
|
+
for (expected in listOf(
|
|
30
|
+
"init",
|
|
31
|
+
"start",
|
|
32
|
+
"stop",
|
|
33
|
+
"status",
|
|
34
|
+
"downloadModel",
|
|
35
|
+
"addProgressListener",
|
|
36
|
+
"removeProgressListener",
|
|
37
|
+
)) {
|
|
38
|
+
assertTrue("missing method: $expected (had: $fnNames)", expected in fnNames)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun `public properties include progressFlow and reactive`() {
|
|
44
|
+
val propNames = DVAIBridge::class.memberProperties.map { it.name }.toSet()
|
|
45
|
+
for (expected in listOf("progressFlow", "reactive")) {
|
|
46
|
+
assertTrue("missing property: $expected (had: $propNames)", expected in propNames)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun `BackendKind has the expected 4 cases`() {
|
|
52
|
+
// Cross-platform parity: same 4 cases as the iOS Android-relevant
|
|
53
|
+
// subset (iOS adds Foundation / CoreML / MLX which don't exist on
|
|
54
|
+
// Android; Android's MediaPipe / LiteRT don't exist on iOS).
|
|
55
|
+
assertEquals(4, BackendKind.values().size)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun `StartOptions defaults match iOS DVAIBridgeConfig defaults`() {
|
|
60
|
+
val opts = StartOptions()
|
|
61
|
+
assertEquals(BackendKind.Auto, opts.backend)
|
|
62
|
+
assertEquals(2048, opts.contextSize)
|
|
63
|
+
assertEquals(4, opts.threads)
|
|
64
|
+
assertEquals(38883, opts.httpBasePort)
|
|
65
|
+
assertEquals(16, opts.httpMaxPortAttempts)
|
|
66
|
+
assertEquals(99, opts.gpuLayers)
|
|
67
|
+
assertEquals(512, opts.maxNewTokens)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
fun `DVAIBridgeError sealed class has expected cases`() {
|
|
72
|
+
// Sanity-check error case names — consumer try-catch blocks should
|
|
73
|
+
// be able to pattern-match on each of these.
|
|
74
|
+
val expected = setOf(
|
|
75
|
+
"AlreadyStarted",
|
|
76
|
+
"ConfigurationInvalid",
|
|
77
|
+
"ModelLoadFailed",
|
|
78
|
+
"BackendUnavailable",
|
|
79
|
+
"BackendError",
|
|
80
|
+
"ChecksumMismatch",
|
|
81
|
+
"DownloadFailed",
|
|
82
|
+
)
|
|
83
|
+
val actual = DVAIBridgeError::class.sealedSubclasses.map { it.simpleName }.toSet()
|
|
84
|
+
assertEquals(expected, actual)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import co.deepvoiceai.bridge.shared.core.discovery.Peer
|
|
4
|
+
import co.deepvoiceai.bridge.shared.core.discovery.PeerSource
|
|
5
|
+
import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertNotNull
|
|
8
|
+
import org.junit.Assert.assertNull
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
import org.junit.runner.RunWith
|
|
12
|
+
import org.robolectric.RobolectricTestRunner
|
|
13
|
+
import org.robolectric.annotation.Config
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* v3.2 — pre-routing decision logic.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors `packages/dvai-bridge-core/src/__tests__/offload-decide.test.ts`
|
|
19
|
+
* for the canonical TS [decide] function. This test exercises the
|
|
20
|
+
* Kotlin port living inside [OffloadProxy] (private; exposed as
|
|
21
|
+
* `internal` for testing).
|
|
22
|
+
*
|
|
23
|
+
* Doesn't bind a server — only constructs an OffloadProxy and calls
|
|
24
|
+
* the route-decision function with synthetic peers. No HTTP, no
|
|
25
|
+
* Ktor lifecycle, fast.
|
|
26
|
+
*/
|
|
27
|
+
@RunWith(RobolectricTestRunner::class)
|
|
28
|
+
@Config(sdk = [34])
|
|
29
|
+
class OffloadProxyDecisionTest {
|
|
30
|
+
|
|
31
|
+
private fun makeProxy(
|
|
32
|
+
backendBaseUrl: String? = "http://127.0.0.1:38983",
|
|
33
|
+
offloadEnabled: Boolean = true,
|
|
34
|
+
minLocalCapability: Double = 10.0,
|
|
35
|
+
) = OffloadProxy(
|
|
36
|
+
backendBaseUrl = backendBaseUrl,
|
|
37
|
+
offloadConfig = OffloadConfig(
|
|
38
|
+
enabled = offloadEnabled,
|
|
39
|
+
minLocalCapability = minLocalCapability,
|
|
40
|
+
),
|
|
41
|
+
pairingPolicy = null,
|
|
42
|
+
discovery = null,
|
|
43
|
+
appId = "test.app",
|
|
44
|
+
selfDeviceId = "test-self-device",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
private fun peer(
|
|
48
|
+
deviceId: String,
|
|
49
|
+
modelScore: Map<String, Double>,
|
|
50
|
+
loadedModels: List<String> = emptyList(),
|
|
51
|
+
) = Peer(
|
|
52
|
+
deviceId = deviceId,
|
|
53
|
+
deviceName = "$deviceId-name",
|
|
54
|
+
dvaiVersion = "3.2.0",
|
|
55
|
+
baseUrl = "http://10.0.0.1:38883",
|
|
56
|
+
loadedModels = loadedModels,
|
|
57
|
+
capability = modelScore,
|
|
58
|
+
via = PeerSource.MDNS,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
/* ------------------------------------------------------------------ */
|
|
62
|
+
/* pickBestPeer */
|
|
63
|
+
/* ------------------------------------------------------------------ */
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun `pickBestPeer returns null when no peers`() {
|
|
67
|
+
val proxy = makeProxy()
|
|
68
|
+
assertNull(proxy.pickBestPeer(emptyList(), "model-a"))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun `pickBestPeer prefers higher score`() {
|
|
73
|
+
val proxy = makeProxy()
|
|
74
|
+
val a = peer("a", mapOf("model-a" to 5.0))
|
|
75
|
+
val b = peer("b", mapOf("model-a" to 30.0))
|
|
76
|
+
val c = peer("c", mapOf("model-a" to 12.0))
|
|
77
|
+
val best = proxy.pickBestPeer(listOf(a, b, c), "model-a")
|
|
78
|
+
assertEquals("b", best?.peer?.deviceId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test
|
|
82
|
+
fun `pickBestPeer prefers peer with model already loaded`() {
|
|
83
|
+
val proxy = makeProxy()
|
|
84
|
+
val notLoaded = peer("a", mapOf("model-a" to 30.0))
|
|
85
|
+
val loaded = peer("b", mapOf("model-a" to 20.0), loadedModels = listOf("model-a"))
|
|
86
|
+
val best = proxy.pickBestPeer(listOf(notLoaded, loaded), "model-a")
|
|
87
|
+
assertEquals("b", best?.peer?.deviceId)
|
|
88
|
+
assertTrue(best!!.hasModel)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Test
|
|
92
|
+
fun `pickBestPeer skips peers with zero score for the model`() {
|
|
93
|
+
val proxy = makeProxy()
|
|
94
|
+
val none = peer("a", mapOf("other" to 100.0))
|
|
95
|
+
val zero = peer("b", mapOf("model-a" to 0.0))
|
|
96
|
+
assertNull(proxy.pickBestPeer(listOf(none, zero), "model-a"))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
/* decideRoute */
|
|
101
|
+
/* ------------------------------------------------------------------ */
|
|
102
|
+
|
|
103
|
+
private val emptyHeaders = emptyMap<String, String>()
|
|
104
|
+
private fun headers(vararg pairs: Pair<String, String>) = pairs.toMap()
|
|
105
|
+
|
|
106
|
+
@Test
|
|
107
|
+
fun `decideRoute non-chat-completion path goes Local`() {
|
|
108
|
+
val proxy = makeProxy()
|
|
109
|
+
val decision = proxy.decideRoute("/v1/embeddings", ByteArray(0), emptyHeaders)
|
|
110
|
+
assertTrue(
|
|
111
|
+
"expected Local, got $decision",
|
|
112
|
+
decision is OffloadProxy.RouteDecision.Local,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
fun `decideRoute non-chat-completion with no backend gives NoCapableDevice`() {
|
|
118
|
+
val proxy = makeProxy(backendBaseUrl = null)
|
|
119
|
+
val decision = proxy.decideRoute("/v1/embeddings", ByteArray(0), emptyHeaders)
|
|
120
|
+
assertTrue(decision is OffloadProxy.RouteDecision.NoCapableDevice)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@Test
|
|
124
|
+
fun `decideRoute chat-completion + offload disabled goes Local`() {
|
|
125
|
+
val proxy = makeProxy(offloadEnabled = false)
|
|
126
|
+
val decision = proxy.decideRoute(
|
|
127
|
+
"/v1/chat/completions",
|
|
128
|
+
"""{"model":"m","stream":false}""".toByteArray(),
|
|
129
|
+
emptyHeaders,
|
|
130
|
+
)
|
|
131
|
+
assertTrue(decision is OffloadProxy.RouteDecision.Local)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Test
|
|
135
|
+
fun `decideRoute X-DVAI-Offload never forces local even with peer available`() {
|
|
136
|
+
val proxy = makeProxy()
|
|
137
|
+
val decision = proxy.decideRoute(
|
|
138
|
+
"/v1/chat/completions",
|
|
139
|
+
"""{"model":"m"}""".toByteArray(),
|
|
140
|
+
headers("x-dvai-offload" to "never"),
|
|
141
|
+
)
|
|
142
|
+
assertTrue(decision is OffloadProxy.RouteDecision.Local)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import co.deepvoiceai.bridge.shared.core.discovery.Peer
|
|
4
|
+
import co.deepvoiceai.bridge.shared.core.discovery.PeerSource
|
|
5
|
+
import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
|
|
6
|
+
import co.deepvoiceai.bridge.shared.core.pairing.Pairing
|
|
7
|
+
import co.deepvoiceai.bridge.shared.core.pairing.PairingPolicy
|
|
8
|
+
import co.deepvoiceai.bridge.shared.core.pairing.PairingSource
|
|
9
|
+
import co.deepvoiceai.bridge.shared.core.pairing.PairingStore
|
|
10
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
11
|
+
import okhttp3.OkHttpClient
|
|
12
|
+
import okhttp3.Request
|
|
13
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
14
|
+
import okhttp3.mockwebserver.MockResponse
|
|
15
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
16
|
+
import org.junit.After
|
|
17
|
+
import org.junit.Assert.assertEquals
|
|
18
|
+
import org.junit.Assert.assertNotNull
|
|
19
|
+
import org.junit.Assert.assertNull
|
|
20
|
+
import org.junit.Assert.assertTrue
|
|
21
|
+
import org.junit.Before
|
|
22
|
+
import org.junit.Test
|
|
23
|
+
import org.junit.runner.RunWith
|
|
24
|
+
import org.robolectric.RobolectricTestRunner
|
|
25
|
+
import org.robolectric.RuntimeEnvironment
|
|
26
|
+
import org.robolectric.annotation.Config
|
|
27
|
+
import java.util.concurrent.TimeUnit
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* v3.2 — full-loop integration test for [OffloadProxy].
|
|
31
|
+
*
|
|
32
|
+
* Spins up two [MockWebServer] instances inside the JVM:
|
|
33
|
+
*
|
|
34
|
+
* - `backend` — stands in for the native backend's loopback HTTP
|
|
35
|
+
* server. Always replies with a JSON body containing
|
|
36
|
+
* `{"served_by":"backend"}` so tests can assert "did the proxy
|
|
37
|
+
* forward to here".
|
|
38
|
+
*
|
|
39
|
+
* - `peer` — stands in for a remote peer that the proxy might
|
|
40
|
+
* forward to. Replies with `{"served_by":"peer"}`.
|
|
41
|
+
*
|
|
42
|
+
* The OffloadProxy is constructed with the peerProvider lambda
|
|
43
|
+
* pointing at a fixed `Peer` whose baseUrl is `peer`'s URL. Tests
|
|
44
|
+
* make a real OkHttp request to the proxy's bound port and then
|
|
45
|
+
* inspect:
|
|
46
|
+
*
|
|
47
|
+
* - which MockWebServer received a request (`takeRequest()` blocks
|
|
48
|
+
* for up to 1s)
|
|
49
|
+
* - the response body so we can tell which path executed
|
|
50
|
+
* - the request headers received by the peer (HMAC signature
|
|
51
|
+
* headers should be present on offload-routed requests)
|
|
52
|
+
*/
|
|
53
|
+
@RunWith(RobolectricTestRunner::class)
|
|
54
|
+
@Config(sdk = [34])
|
|
55
|
+
class OffloadProxyForwardingTest {
|
|
56
|
+
|
|
57
|
+
private lateinit var backend: MockWebServer
|
|
58
|
+
private lateinit var peer: MockWebServer
|
|
59
|
+
private val client = OkHttpClient.Builder()
|
|
60
|
+
.callTimeout(5, TimeUnit.SECONDS)
|
|
61
|
+
.build()
|
|
62
|
+
|
|
63
|
+
// Base64-url-encoded 256-bit pairing key (matches the v3.2.1 wire
|
|
64
|
+
// format — `OffloadProxy.signCanonical` decodes the key as
|
|
65
|
+
// base64-url before HMAC-keying, mirroring the TS Hub's
|
|
66
|
+
// `verifyHmac`). The test asserts header shape, not signature
|
|
67
|
+
// value, so the exact bytes don't matter — only that the string
|
|
68
|
+
// is decodable.
|
|
69
|
+
private val pairingKey = "vGzn8h_FNHkqL5Q1tN-rTu3pYWB7K0vGzn8h_FNHkqI"
|
|
70
|
+
private val peerDeviceId = "peer-device-id"
|
|
71
|
+
|
|
72
|
+
@Before
|
|
73
|
+
fun setup() {
|
|
74
|
+
backend = MockWebServer().apply { start() }
|
|
75
|
+
peer = MockWebServer().apply { start() }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@After
|
|
79
|
+
fun teardown() {
|
|
80
|
+
backend.shutdown()
|
|
81
|
+
peer.shutdown()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private fun makeProxy(
|
|
85
|
+
backendBaseUrlOverride: String? = backend.url("/").toString().trimEnd('/'),
|
|
86
|
+
offloadEnabled: Boolean = true,
|
|
87
|
+
peers: List<Peer> = emptyList(),
|
|
88
|
+
pairingPolicy: PairingPolicy? = null,
|
|
89
|
+
minLocalCapability: Double = 10.0,
|
|
90
|
+
): OffloadProxy = OffloadProxy(
|
|
91
|
+
backendBaseUrl = backendBaseUrlOverride,
|
|
92
|
+
offloadConfig = OffloadConfig(
|
|
93
|
+
enabled = offloadEnabled,
|
|
94
|
+
minLocalCapability = minLocalCapability,
|
|
95
|
+
),
|
|
96
|
+
pairingPolicy = pairingPolicy,
|
|
97
|
+
peerProvider = { peers },
|
|
98
|
+
appId = "co.test.app",
|
|
99
|
+
selfDeviceId = "self-device-id",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
private fun fakePeer(
|
|
103
|
+
scoreForModel: Double = 30.0,
|
|
104
|
+
modelId: String = "test-model",
|
|
105
|
+
) = Peer(
|
|
106
|
+
deviceId = peerDeviceId,
|
|
107
|
+
deviceName = "test-peer",
|
|
108
|
+
dvaiVersion = "3.2.0",
|
|
109
|
+
baseUrl = peer.url("/").toString().trimEnd('/'),
|
|
110
|
+
loadedModels = listOf(modelId),
|
|
111
|
+
capability = mapOf(modelId to scoreForModel),
|
|
112
|
+
via = PeerSource.MDNS,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
/* Local-only path */
|
|
117
|
+
/* ------------------------------------------------------------------ */
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun `chat-completion forwards to backend when no peers + offload disabled`() {
|
|
121
|
+
backend.enqueue(
|
|
122
|
+
MockResponse()
|
|
123
|
+
.setResponseCode(200)
|
|
124
|
+
.setHeader("Content-Type", "application/json")
|
|
125
|
+
.setBody("""{"served_by":"backend"}"""),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
val proxy = makeProxy(offloadEnabled = false)
|
|
129
|
+
val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
|
|
130
|
+
try {
|
|
131
|
+
val resp = postJson("http://127.0.0.1:$proxyPort/v1/chat/completions", """{"model":"m"}""")
|
|
132
|
+
assertEquals(200, resp.code)
|
|
133
|
+
assertTrue(resp.body!!.contains(""""served_by":"backend""""))
|
|
134
|
+
} finally {
|
|
135
|
+
proxy.stop()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
val recorded = backend.takeRequest(1, TimeUnit.SECONDS)
|
|
139
|
+
assertNotNull("backend should have received exactly one request", recorded)
|
|
140
|
+
assertEquals("/v1/chat/completions", recorded!!.path)
|
|
141
|
+
|
|
142
|
+
// No request should hit the peer.
|
|
143
|
+
assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* Offload path — `prefer` (default) routes to peer when score high */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun `chat-completion forwards to peer when offload prefer + peer score above threshold`() {
|
|
152
|
+
peer.enqueue(
|
|
153
|
+
MockResponse()
|
|
154
|
+
.setResponseCode(200)
|
|
155
|
+
.setHeader("Content-Type", "application/json")
|
|
156
|
+
.setBody("""{"served_by":"peer"}"""),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
val proxy = makeProxy(
|
|
160
|
+
peers = listOf(fakePeer(scoreForModel = 50.0)),
|
|
161
|
+
pairingPolicy = pairingPolicyWithKey(),
|
|
162
|
+
minLocalCapability = 10.0,
|
|
163
|
+
)
|
|
164
|
+
val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
|
|
165
|
+
try {
|
|
166
|
+
val resp = postJson(
|
|
167
|
+
"http://127.0.0.1:$proxyPort/v1/chat/completions",
|
|
168
|
+
"""{"model":"test-model"}""",
|
|
169
|
+
)
|
|
170
|
+
assertEquals(200, resp.code)
|
|
171
|
+
assertTrue(resp.body!!.contains(""""served_by":"peer""""))
|
|
172
|
+
} finally {
|
|
173
|
+
proxy.stop()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Peer received it; backend did not.
|
|
177
|
+
val peerReq = peer.takeRequest(1, TimeUnit.SECONDS)
|
|
178
|
+
assertNotNull("peer should have received the offloaded request", peerReq)
|
|
179
|
+
assertEquals("/v1/chat/completions", peerReq!!.path)
|
|
180
|
+
|
|
181
|
+
// Identity headers must be present on peer-bound forwards.
|
|
182
|
+
assertEquals("self-device-id", peerReq.getHeader("X-DVAI-Peer-Device-Id"))
|
|
183
|
+
assertEquals("co.test.app", peerReq.getHeader("X-DVAI-App-Id"))
|
|
184
|
+
assertNotNull(peerReq.getHeader("X-DVAI-Nonce"))
|
|
185
|
+
assertNotNull(peerReq.getHeader("X-DVAI-Signature"))
|
|
186
|
+
assertEquals("1", peerReq.getHeader("X-DVAI-Forwarded"))
|
|
187
|
+
|
|
188
|
+
assertNull(backend.takeRequest(100, TimeUnit.MILLISECONDS))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* `never` header forces local even with peers */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
@Test
|
|
196
|
+
fun `X-DVAI-Offload never forces local even with strong peer`() {
|
|
197
|
+
backend.enqueue(
|
|
198
|
+
MockResponse()
|
|
199
|
+
.setResponseCode(200)
|
|
200
|
+
.setHeader("Content-Type", "application/json")
|
|
201
|
+
.setBody("""{"served_by":"backend"}"""),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
val proxy = makeProxy(
|
|
205
|
+
peers = listOf(fakePeer(scoreForModel = 100.0)),
|
|
206
|
+
pairingPolicy = pairingPolicyWithKey(),
|
|
207
|
+
)
|
|
208
|
+
val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
|
|
209
|
+
try {
|
|
210
|
+
val resp = postJson(
|
|
211
|
+
"http://127.0.0.1:$proxyPort/v1/chat/completions",
|
|
212
|
+
"""{"model":"test-model"}""",
|
|
213
|
+
extraHeaders = mapOf("X-DVAI-Offload" to "never"),
|
|
214
|
+
)
|
|
215
|
+
assertEquals(200, resp.code)
|
|
216
|
+
assertTrue(resp.body!!.contains(""""served_by":"backend""""))
|
|
217
|
+
} finally {
|
|
218
|
+
proxy.stop()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
assertNotNull(backend.takeRequest(1, TimeUnit.SECONDS))
|
|
222
|
+
assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------ */
|
|
226
|
+
/* `require` header without peers → 503 */
|
|
227
|
+
/* ------------------------------------------------------------------ */
|
|
228
|
+
|
|
229
|
+
@Test
|
|
230
|
+
fun `X-DVAI-Offload require with no peers returns no_capable_device`() {
|
|
231
|
+
val proxy = makeProxy(peers = emptyList())
|
|
232
|
+
val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
|
|
233
|
+
try {
|
|
234
|
+
val resp = postJson(
|
|
235
|
+
"http://127.0.0.1:$proxyPort/v1/chat/completions",
|
|
236
|
+
"""{"model":"test-model"}""",
|
|
237
|
+
extraHeaders = mapOf("X-DVAI-Offload" to "require"),
|
|
238
|
+
)
|
|
239
|
+
assertEquals(503, resp.code)
|
|
240
|
+
assertTrue(resp.body!!.contains("no_capable_device"))
|
|
241
|
+
} finally {
|
|
242
|
+
proxy.stop()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
assertNull(backend.takeRequest(100, TimeUnit.MILLISECONDS))
|
|
246
|
+
assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ------------------------------------------------------------------ */
|
|
250
|
+
/* Offload-only mode (no backend) + no peers → 503 */
|
|
251
|
+
/* ------------------------------------------------------------------ */
|
|
252
|
+
|
|
253
|
+
@Test
|
|
254
|
+
fun `offload-only mode with no peers returns no_local_backend`() {
|
|
255
|
+
val proxy = makeProxy(
|
|
256
|
+
backendBaseUrlOverride = null,
|
|
257
|
+
peers = emptyList(),
|
|
258
|
+
)
|
|
259
|
+
val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
|
|
260
|
+
try {
|
|
261
|
+
val resp = postJson(
|
|
262
|
+
"http://127.0.0.1:$proxyPort/v1/chat/completions",
|
|
263
|
+
"""{"model":"test-model"}""",
|
|
264
|
+
)
|
|
265
|
+
assertEquals(503, resp.code)
|
|
266
|
+
// either no_local_backend (no offload) or no_capable_device (offload but no peers)
|
|
267
|
+
assertTrue(
|
|
268
|
+
"expected 503 with structured error, got: ${resp.body}",
|
|
269
|
+
resp.body!!.contains("no_local_backend") || resp.body.contains("no_capable_device"),
|
|
270
|
+
)
|
|
271
|
+
} finally {
|
|
272
|
+
proxy.stop()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ------------------------------------------------------------------ */
|
|
277
|
+
/* Helpers */
|
|
278
|
+
/* ------------------------------------------------------------------ */
|
|
279
|
+
|
|
280
|
+
private data class HttpResponseSnapshot(val code: Int, val body: String?)
|
|
281
|
+
|
|
282
|
+
/** Ask the OS for a free port, then close the socket so the proxy
|
|
283
|
+
* can bind it on the next syscall. There's a tiny race window
|
|
284
|
+
* but it's acceptable for unit tests. */
|
|
285
|
+
private fun freePort(): Int {
|
|
286
|
+
val socket = java.net.ServerSocket(0)
|
|
287
|
+
val port = socket.localPort
|
|
288
|
+
socket.close()
|
|
289
|
+
return port
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private fun postJson(
|
|
293
|
+
url: String,
|
|
294
|
+
body: String,
|
|
295
|
+
extraHeaders: Map<String, String> = emptyMap(),
|
|
296
|
+
): HttpResponseSnapshot {
|
|
297
|
+
val req = Request.Builder()
|
|
298
|
+
.url(url)
|
|
299
|
+
.post(body.toRequestBody("application/json".toMediaType()))
|
|
300
|
+
.apply { for ((k, v) in extraHeaders) header(k, v) }
|
|
301
|
+
.build()
|
|
302
|
+
return client.newCall(req).execute().use {
|
|
303
|
+
HttpResponseSnapshot(it.code, it.body?.string())
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private fun pairingPolicyWithKey(): PairingPolicy {
|
|
308
|
+
// PairingStore writes to context.cacheDir/dvai-bridge — Robolectric
|
|
309
|
+
// provides a real cache dir. Pre-seed a pairing for our fake peer
|
|
310
|
+
// so the proxy's HMAC sign step finds a key.
|
|
311
|
+
val ctx = RuntimeEnvironment.getApplication()
|
|
312
|
+
val store = PairingStore(ctx)
|
|
313
|
+
store.clear()
|
|
314
|
+
store.set(
|
|
315
|
+
Pairing(
|
|
316
|
+
peerDeviceId = peerDeviceId,
|
|
317
|
+
peerDeviceName = "test-peer",
|
|
318
|
+
pairingKey = pairingKey,
|
|
319
|
+
pairedAt = System.currentTimeMillis(),
|
|
320
|
+
lastUsedAt = System.currentTimeMillis(),
|
|
321
|
+
via = PairingSource.LAN_HANDSHAKE,
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
return PairingPolicy(store)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.flow.first
|
|
4
|
+
import kotlinx.coroutines.flow.take
|
|
5
|
+
import kotlinx.coroutines.flow.toList
|
|
6
|
+
import kotlinx.coroutines.test.runTest
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
|
|
11
|
+
class ProgressBroadcasterTest {
|
|
12
|
+
@Test
|
|
13
|
+
fun `flow and listener both receive emitted events`() = runTest {
|
|
14
|
+
val b = ProgressBroadcaster()
|
|
15
|
+
val captured = mutableListOf<ProgressEvent>()
|
|
16
|
+
b.addListener { captured.add(it) }
|
|
17
|
+
|
|
18
|
+
// Emit before any flow subscriber — the SharedFlow's replay buffer
|
|
19
|
+
// should make the most-recent event visible to a late subscriber.
|
|
20
|
+
b.emit(ProgressEvent.Started("phase-1"))
|
|
21
|
+
|
|
22
|
+
val replayed = b.flow.first()
|
|
23
|
+
assertTrue("late subscriber should see replay", replayed is ProgressEvent.Started)
|
|
24
|
+
|
|
25
|
+
b.emit(ProgressEvent.Progress("phase-1", percent = 0.5f, message = "halfway"))
|
|
26
|
+
b.emit(ProgressEvent.Completed("phase-1"))
|
|
27
|
+
|
|
28
|
+
// Listener saw all 3 events synchronously on the emit thread.
|
|
29
|
+
assertEquals(3, captured.size)
|
|
30
|
+
assertTrue(captured[0] is ProgressEvent.Started)
|
|
31
|
+
assertTrue(captured[1] is ProgressEvent.Progress)
|
|
32
|
+
assertTrue(captured[2] is ProgressEvent.Completed)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `removed listener stops receiving events`() {
|
|
37
|
+
val b = ProgressBroadcaster()
|
|
38
|
+
val captured = mutableListOf<ProgressEvent>()
|
|
39
|
+
val listener = ProgressListener { captured.add(it) }
|
|
40
|
+
b.addListener(listener)
|
|
41
|
+
b.emit(ProgressEvent.Started("p"))
|
|
42
|
+
b.removeListener(listener)
|
|
43
|
+
b.emit(ProgressEvent.Completed("p"))
|
|
44
|
+
assertEquals(1, captured.size)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test
|
|
48
|
+
fun `listener exception does not block other listeners`() {
|
|
49
|
+
val b = ProgressBroadcaster()
|
|
50
|
+
val good = mutableListOf<ProgressEvent>()
|
|
51
|
+
b.addListener { throw RuntimeException("misbehaving listener") }
|
|
52
|
+
b.addListener { good.add(it) }
|
|
53
|
+
b.emit(ProgressEvent.Started("p"))
|
|
54
|
+
assertEquals(1, good.size)
|
|
55
|
+
}
|
|
56
|
+
}
|