@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.10
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/Package.swift +19 -2
- package/README.md +190 -13
- package/android/build.gradle.kts +12 -6
- package/android/settings.gradle +7 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +175 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -13
- package/dist/streaming.d.ts +17 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +303 -0
- package/dist/web.d.ts +18 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +136 -7
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
- package/package.json +21 -9
package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
package app.independo.inderun.capacitor
|
|
2
|
+
|
|
3
|
+
import app.independo.inderun.contracts.FinishReason
|
|
4
|
+
import app.independo.inderun.contracts.IndeRunErrorClass
|
|
5
|
+
import app.independo.inderun.contracts.Outcome
|
|
6
|
+
import app.independo.inderun.contracts.Payload
|
|
7
|
+
import app.independo.inderun.contracts.PayloadError
|
|
8
|
+
import app.independo.inderun.contracts.PayloadTelemetry
|
|
9
|
+
import app.independo.inderun.contracts.PayloadUsage
|
|
10
|
+
import app.independo.inderun.contracts.Phase
|
|
11
|
+
import app.independo.inderun.contracts.SchemaVersion
|
|
12
|
+
import app.independo.inderun.contracts.StreamEvent
|
|
13
|
+
import app.independo.inderun.contracts.StreamRunHandle
|
|
14
|
+
import org.junit.Assert.assertEquals
|
|
15
|
+
import org.junit.Assert.assertFalse
|
|
16
|
+
import org.junit.Assert.assertTrue
|
|
17
|
+
import org.junit.Test
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Encoding is what the JS side actually consumes, so these assert exact key sets: a
|
|
21
|
+
* field that encodes as null instead of being absent changes which union branch the
|
|
22
|
+
* consumer reconstitutes.
|
|
23
|
+
*/
|
|
24
|
+
class IndeRunSerializerStreamTest {
|
|
25
|
+
|
|
26
|
+
private fun event(
|
|
27
|
+
type: String,
|
|
28
|
+
payload: Payload?,
|
|
29
|
+
sequence: Long = 0,
|
|
30
|
+
runId: String = "run_1"
|
|
31
|
+
): StreamEvent = StreamEvent(
|
|
32
|
+
payload = payload,
|
|
33
|
+
runId = runId,
|
|
34
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
35
|
+
sequence = sequence,
|
|
36
|
+
timestamp = 1_700_000_000_000.0,
|
|
37
|
+
type = type
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
private fun keysOf(json: org.json.JSONObject): Set<String> =
|
|
41
|
+
json.keys().asSequence().toSet()
|
|
42
|
+
|
|
43
|
+
@Test
|
|
44
|
+
fun `encodes a content delta with only the text field`() {
|
|
45
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
46
|
+
event(type = "content_delta", payload = Payload(text = "hello"))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assertEquals("1.0", encoded.getString("schemaVersion"))
|
|
50
|
+
assertEquals("run_1", encoded.getString("runId"))
|
|
51
|
+
assertEquals(0L, encoded.getLong("sequence"))
|
|
52
|
+
assertEquals("content_delta", encoded.getString("type"))
|
|
53
|
+
assertEquals(setOf("text"), keysOf(encoded.getJSONObject("payload")))
|
|
54
|
+
assertEquals("hello", encoded.getJSONObject("payload").getString("text"))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
fun `encodes a completed terminal outcome`() {
|
|
59
|
+
val payload = Payload(
|
|
60
|
+
finalText = "all done",
|
|
61
|
+
finishReason = FinishReason.STOP,
|
|
62
|
+
outcome = Outcome.Completed,
|
|
63
|
+
runId = "run_1",
|
|
64
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
65
|
+
telemetry = PayloadTelemetry(providerUsed = "openai", totalMs = 42.0),
|
|
66
|
+
usage = PayloadUsage(inputTokens = 3, outputTokens = 5, totalTokens = 8)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
70
|
+
event(type = "terminal", payload = payload, sequence = 4)
|
|
71
|
+
).getJSONObject("payload")
|
|
72
|
+
|
|
73
|
+
assertEquals("completed", encoded.getString("outcome"))
|
|
74
|
+
assertEquals("all done", encoded.getString("finalText"))
|
|
75
|
+
assertEquals("stop", encoded.getString("finishReason"))
|
|
76
|
+
assertEquals(8L, encoded.getJSONObject("usage").getLong("totalTokens"))
|
|
77
|
+
assertEquals("openai", encoded.getJSONObject("telemetry").getString("providerUsed"))
|
|
78
|
+
// The other branches' fields must not leak into a completion.
|
|
79
|
+
assertFalse(encoded.has("error"))
|
|
80
|
+
assertFalse(encoded.has("partialText"))
|
|
81
|
+
assertFalse(encoded.has("reason"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test
|
|
85
|
+
fun `encodes an error terminal outcome with its nested error`() {
|
|
86
|
+
val payload = Payload(
|
|
87
|
+
outcome = Outcome.Error,
|
|
88
|
+
runId = "run_1",
|
|
89
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
90
|
+
error = PayloadError(
|
|
91
|
+
details = mapOf("endpoint" to "https://example.test"),
|
|
92
|
+
errorClass = IndeRunErrorClass.RateLimited,
|
|
93
|
+
message = "Too many requests.",
|
|
94
|
+
providerId = "openai",
|
|
95
|
+
retryable = true,
|
|
96
|
+
retryAfterMs = 2_000
|
|
97
|
+
),
|
|
98
|
+
partialText = "half "
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
102
|
+
event(type = "terminal", payload = payload, sequence = 7)
|
|
103
|
+
).getJSONObject("payload")
|
|
104
|
+
|
|
105
|
+
assertEquals("error", encoded.getString("outcome"))
|
|
106
|
+
assertEquals("half ", encoded.getString("partialText"))
|
|
107
|
+
|
|
108
|
+
val error = encoded.getJSONObject("error")
|
|
109
|
+
assertEquals("RateLimited", error.getString("errorClass"))
|
|
110
|
+
assertEquals(2_000L, error.getLong("retryAfterMs"))
|
|
111
|
+
assertTrue(error.getBoolean("retryable"))
|
|
112
|
+
assertEquals(
|
|
113
|
+
"https://example.test",
|
|
114
|
+
error.getJSONObject("details").getString("endpoint")
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun `omits the cancellation reason entirely when there is none`() {
|
|
120
|
+
val withReason = IndeRunSerializer.encodeStreamEvent(
|
|
121
|
+
event(
|
|
122
|
+
type = "terminal",
|
|
123
|
+
payload = Payload(
|
|
124
|
+
outcome = Outcome.Cancelled,
|
|
125
|
+
partialText = "part",
|
|
126
|
+
reason = "user left"
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
).getJSONObject("payload")
|
|
130
|
+
assertEquals("user left", withReason.getString("reason"))
|
|
131
|
+
|
|
132
|
+
val withoutReason = IndeRunSerializer.encodeStreamEvent(
|
|
133
|
+
event(
|
|
134
|
+
type = "terminal",
|
|
135
|
+
payload = Payload(outcome = Outcome.Cancelled, partialText = "part")
|
|
136
|
+
)
|
|
137
|
+
).getJSONObject("payload")
|
|
138
|
+
|
|
139
|
+
assertEquals("cancelled", withoutReason.getString("outcome"))
|
|
140
|
+
assertEquals("part", withoutReason.getString("partialText"))
|
|
141
|
+
// Absent, not null: a null would decode as a present-but-empty reason.
|
|
142
|
+
assertFalse(withoutReason.has("reason"))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@Test
|
|
146
|
+
fun `encodes a lifecycle phase using its snake case wire value`() {
|
|
147
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
148
|
+
event(type = "lifecycle", payload = Payload(phase = Phase.ProviderSelected))
|
|
149
|
+
).getJSONObject("payload")
|
|
150
|
+
|
|
151
|
+
assertEquals("provider_selected", encoded.getString("phase"))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The schema closes its union with an open catch-all branch, so an unrecognized
|
|
156
|
+
* type must cross the bridge untouched rather than be rejected — unlike the
|
|
157
|
+
* inbound request enums, which throw.
|
|
158
|
+
*/
|
|
159
|
+
@Test
|
|
160
|
+
fun `passes an unknown event type through verbatim without a payload`() {
|
|
161
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
162
|
+
event(type = "some_future_type", payload = null, sequence = 2)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
assertEquals("some_future_type", encoded.getString("type"))
|
|
166
|
+
assertFalse(encoded.has("payload"))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Test
|
|
170
|
+
fun `encodes a diagnostic event with an empty payload object`() {
|
|
171
|
+
val encoded = IndeRunSerializer.encodeStreamEvent(
|
|
172
|
+
event(type = "diagnostic", payload = Payload())
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
assertTrue(encoded.has("payload"))
|
|
176
|
+
assertEquals(emptySet<String>(), keysOf(encoded.getJSONObject("payload")))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@Test
|
|
180
|
+
fun `encodes a stream run handle with and without a provider id`() {
|
|
181
|
+
val withProvider = IndeRunSerializer.encodeStreamRunHandle(
|
|
182
|
+
StreamRunHandle(
|
|
183
|
+
providerId = "openai",
|
|
184
|
+
runId = "run_1",
|
|
185
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
186
|
+
startedAt = 1_700_000_000_000.0
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
assertEquals("openai", withProvider.getString("providerId"))
|
|
190
|
+
assertEquals("run_1", withProvider.getString("runId"))
|
|
191
|
+
assertEquals(1_700_000_000_000.0, withProvider.getDouble("startedAt"), 0.0)
|
|
192
|
+
|
|
193
|
+
val withoutProvider = IndeRunSerializer.encodeStreamRunHandle(
|
|
194
|
+
StreamRunHandle(
|
|
195
|
+
runId = "run_1",
|
|
196
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
197
|
+
startedAt = 1_700_000_000_000.0
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
assertFalse(withoutProvider.has("providerId"))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Outcome and Phase are generated without a rawValue, so their wire strings are
|
|
205
|
+
* hand-written. If a regeneration adds a constant, the `when` stops compiling —
|
|
206
|
+
* and if one is ever mapped to the wrong casing, this catches it.
|
|
207
|
+
*/
|
|
208
|
+
@Test
|
|
209
|
+
fun `every outcome and phase constant maps to a snake case wire value`() {
|
|
210
|
+
val outcomes = Outcome.entries.map { outcome ->
|
|
211
|
+
IndeRunSerializer.encodeStreamEvent(
|
|
212
|
+
event(type = "terminal", payload = Payload(outcome = outcome))
|
|
213
|
+
).getJSONObject("payload").getString("outcome")
|
|
214
|
+
}
|
|
215
|
+
assertEquals(listOf("cancelled", "completed", "error"), outcomes)
|
|
216
|
+
|
|
217
|
+
val phases = Phase.entries.map { phase ->
|
|
218
|
+
IndeRunSerializer.encodeStreamEvent(
|
|
219
|
+
event(type = "lifecycle", payload = Payload(phase = phase))
|
|
220
|
+
).getJSONObject("payload").getString("phase")
|
|
221
|
+
}
|
|
222
|
+
assertEquals(listOf("provider_selected", "started"), phases)
|
|
223
|
+
|
|
224
|
+
assertTrue((outcomes + phases).all { it.isNotEmpty() && it == it.lowercase() })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -142,6 +142,29 @@ class IndeRunSerializerTest {
|
|
|
142
142
|
|
|
143
143
|
// --- encodeError ---
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* `systemModel` and `onnx` are web-only bootstrap keys, like
|
|
147
|
+
* `allowDirectOpenAIEndpoint`. Android registers its own on-device provider, so it must
|
|
148
|
+
* ignore them rather than fail on the options object that carries them.
|
|
149
|
+
*/
|
|
150
|
+
@Test
|
|
151
|
+
fun `parseConfigureOptions ignores the web-only provider bootstrap keys`() {
|
|
152
|
+
val options = IndeRunSerializer.parseConfigureOptions(
|
|
153
|
+
JSONObject(
|
|
154
|
+
"""
|
|
155
|
+
{
|
|
156
|
+
"openAI": { "model": "gpt-5.2", "auth": "none" },
|
|
157
|
+
"systemModel": { "id": "local.system-model.web", "timeoutMs": 30000 },
|
|
158
|
+
"onnx": { "modelPackage": { "id": "demo", "format": "onnx" } }
|
|
159
|
+
}
|
|
160
|
+
""".trimIndent()
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assertEquals("gpt-5.2", options.openAI?.model)
|
|
165
|
+
assertEquals("none", options.openAI?.auth)
|
|
166
|
+
}
|
|
167
|
+
|
|
145
168
|
@Test
|
|
146
169
|
fun `encodeError produces correct shape with required fields only`() {
|
|
147
170
|
val encoded = IndeRunSerializer.encodeError(
|
|
@@ -177,4 +200,60 @@ class IndeRunSerializerTest {
|
|
|
177
200
|
assertEquals(2000L, encoded.getLong("retryAfterMs"))
|
|
178
201
|
assertEquals("run_abc", encoded.getString("runId"))
|
|
179
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A routing refusal carries its plan diagnostics on the error's `details`, and since
|
|
206
|
+
* inderun 0.3.0 it does so on iOS and Android too, not just on the web SDK. The shape
|
|
207
|
+
* is nested — a `rejectedProviders` array of objects, each with its own `reasons`
|
|
208
|
+
* array — and `encodeError` hands the whole map to `JSONObject(Map)`, which deep-wraps
|
|
209
|
+
* via `wrap()`. Nothing asserted that before; this is the data a provider-refusal UI
|
|
210
|
+
* actually reads, so a regression here would be invisible until a demo showed an empty
|
|
211
|
+
* table.
|
|
212
|
+
*/
|
|
213
|
+
@Test
|
|
214
|
+
fun `encodeError preserves nested route-plan diagnostics in details`() {
|
|
215
|
+
val encoded = IndeRunSerializer.encodeError(
|
|
216
|
+
IndeRunError(
|
|
217
|
+
errorClass = IndeRunErrorClass.CapabilityMismatch,
|
|
218
|
+
message = "No eligible provider can stream this request.",
|
|
219
|
+
details = mapOf(
|
|
220
|
+
"failureCode" to "CapabilityMismatch",
|
|
221
|
+
"rejectedProviders" to listOf(
|
|
222
|
+
mapOf(
|
|
223
|
+
"providerId" to "android.mlkit.genai",
|
|
224
|
+
"reasons" to listOf(
|
|
225
|
+
mapOf(
|
|
226
|
+
"code" to "streaming_not_supported",
|
|
227
|
+
"message" to "Provider does not declare streaming."
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
),
|
|
231
|
+
mapOf(
|
|
232
|
+
"providerId" to "openai",
|
|
233
|
+
"reasons" to listOf(
|
|
234
|
+
mapOf("code" to "privacy_constraint", "message" to "local_required forbids cloud.")
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
val details = encoded.getJSONObject("details")
|
|
243
|
+
assertEquals("CapabilityMismatch", details.getString("failureCode"))
|
|
244
|
+
|
|
245
|
+
val rejected = details.getJSONArray("rejectedProviders")
|
|
246
|
+
assertEquals(2, rejected.length())
|
|
247
|
+
assertEquals("android.mlkit.genai", rejected.getJSONObject(0).getString("providerId"))
|
|
248
|
+
assertEquals(
|
|
249
|
+
"streaming_not_supported",
|
|
250
|
+
rejected.getJSONObject(0).getJSONArray("reasons").getJSONObject(0).getString("code")
|
|
251
|
+
)
|
|
252
|
+
assertEquals("openai", rejected.getJSONObject(1).getString("providerId"))
|
|
253
|
+
assertEquals(
|
|
254
|
+
"privacy_constraint",
|
|
255
|
+
rejected.getJSONObject(1).getJSONArray("reasons").getJSONObject(0).getString("code")
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
180
259
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package app.independo.inderun.capacitor
|
|
2
|
+
|
|
3
|
+
import app.independo.inderun.contracts.SchemaVersion
|
|
4
|
+
import app.independo.inderun.contracts.StreamRunHandle
|
|
5
|
+
import app.independo.inderun.core.StreamRun
|
|
6
|
+
import kotlinx.coroutines.Job
|
|
7
|
+
import kotlinx.coroutines.flow.emptyFlow
|
|
8
|
+
import org.junit.Assert.assertEquals
|
|
9
|
+
import org.junit.Assert.assertFalse
|
|
10
|
+
import org.junit.Assert.assertTrue
|
|
11
|
+
import org.junit.Test
|
|
12
|
+
|
|
13
|
+
class IndeRunStreamRegistryTest {
|
|
14
|
+
|
|
15
|
+
private fun run(record: (String?) -> Unit): StreamRun = StreamRun(
|
|
16
|
+
handle = StreamRunHandle(
|
|
17
|
+
runId = "run_1",
|
|
18
|
+
schemaVersion = SchemaVersion.V1_0,
|
|
19
|
+
startedAt = 1_700_000_000_000.0
|
|
20
|
+
),
|
|
21
|
+
events = emptyFlow(),
|
|
22
|
+
onCancel = record
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun `attaches and closes by stream id`() {
|
|
27
|
+
val registry = IndeRunStreamRegistry()
|
|
28
|
+
|
|
29
|
+
registry.open("s1")
|
|
30
|
+
assertEquals(AttachOutcome.Attached, registry.attach("s1", run {}))
|
|
31
|
+
assertEquals(1, registry.activeCount)
|
|
32
|
+
|
|
33
|
+
registry.close("s1")
|
|
34
|
+
assertEquals(0, registry.activeCount)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A cancel can beat the engine's route selection. It must be held and applied when
|
|
39
|
+
* the run finally attaches, not dropped.
|
|
40
|
+
*/
|
|
41
|
+
@Test
|
|
42
|
+
fun `cancel before attach is applied on attach with its reason`() {
|
|
43
|
+
val registry = IndeRunStreamRegistry()
|
|
44
|
+
val reasons = mutableListOf<String?>()
|
|
45
|
+
|
|
46
|
+
registry.open("s1")
|
|
47
|
+
registry.requestCancel("s1", "user left")
|
|
48
|
+
// Nothing to cancel yet, so nothing was called.
|
|
49
|
+
assertTrue(reasons.isEmpty())
|
|
50
|
+
|
|
51
|
+
val outcome = registry.attach("s1", run { reasons.add(it) })
|
|
52
|
+
assertEquals(AttachOutcome.CancelRequested("user left"), outcome)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `cancel after close is a no-op`() {
|
|
57
|
+
val registry = IndeRunStreamRegistry()
|
|
58
|
+
val reasons = mutableListOf<String?>()
|
|
59
|
+
|
|
60
|
+
registry.open("s1")
|
|
61
|
+
registry.attach("s1", run { reasons.add(it) })
|
|
62
|
+
registry.close("s1")
|
|
63
|
+
|
|
64
|
+
registry.requestCancel("s1", "too late")
|
|
65
|
+
assertTrue(reasons.isEmpty())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `cancel for an unknown stream id is a no-op`() {
|
|
70
|
+
val registry = IndeRunStreamRegistry()
|
|
71
|
+
|
|
72
|
+
registry.requestCancel("never_opened", null)
|
|
73
|
+
assertEquals(0, registry.activeCount)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun `repeated cancel cancels the run exactly once`() {
|
|
78
|
+
val registry = IndeRunStreamRegistry()
|
|
79
|
+
val reasons = mutableListOf<String?>()
|
|
80
|
+
|
|
81
|
+
registry.open("s1")
|
|
82
|
+
registry.attach("s1", run { reasons.add(it) })
|
|
83
|
+
|
|
84
|
+
registry.requestCancel("s1", "first")
|
|
85
|
+
registry.requestCancel("s1", "second")
|
|
86
|
+
|
|
87
|
+
assertEquals(listOf("first"), reasons)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test
|
|
91
|
+
fun `concurrent streams are isolated`() {
|
|
92
|
+
val registry = IndeRunStreamRegistry()
|
|
93
|
+
val first = mutableListOf<String?>()
|
|
94
|
+
val second = mutableListOf<String?>()
|
|
95
|
+
|
|
96
|
+
registry.open("s1")
|
|
97
|
+
registry.open("s2")
|
|
98
|
+
registry.attach("s1", run { first.add(it) })
|
|
99
|
+
registry.attach("s2", run { second.add(it) })
|
|
100
|
+
|
|
101
|
+
registry.requestCancel("s1", "only the first")
|
|
102
|
+
|
|
103
|
+
assertEquals(listOf("only the first"), first)
|
|
104
|
+
assertTrue(second.isEmpty())
|
|
105
|
+
assertEquals(2, registry.activeCount)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
fun `cancelAll cancels every run once and empties the registry`() {
|
|
110
|
+
val registry = IndeRunStreamRegistry()
|
|
111
|
+
val first = mutableListOf<String?>()
|
|
112
|
+
val second = mutableListOf<String?>()
|
|
113
|
+
|
|
114
|
+
registry.open("s1")
|
|
115
|
+
registry.open("s2")
|
|
116
|
+
registry.attach("s1", run { first.add(it) })
|
|
117
|
+
registry.attach("s2", run { second.add(it) })
|
|
118
|
+
|
|
119
|
+
registry.cancelAll("torn down")
|
|
120
|
+
|
|
121
|
+
assertEquals(listOf("torn down"), first)
|
|
122
|
+
assertEquals(listOf("torn down"), second)
|
|
123
|
+
assertEquals(0, registry.activeCount)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Teardown also cancels the collecting jobs — at that point nobody is left to
|
|
128
|
+
* receive a terminal event.
|
|
129
|
+
*/
|
|
130
|
+
@Test
|
|
131
|
+
fun `cancelAll cancels the collecting job`() {
|
|
132
|
+
val registry = IndeRunStreamRegistry()
|
|
133
|
+
val job = Job()
|
|
134
|
+
|
|
135
|
+
registry.open("s1")
|
|
136
|
+
registry.attach("s1", run {})
|
|
137
|
+
registry.attachJob("s1", job)
|
|
138
|
+
|
|
139
|
+
assertFalse(job.isCancelled)
|
|
140
|
+
registry.cancelAll(null)
|
|
141
|
+
assertTrue(job.isCancelled)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test
|
|
145
|
+
fun `attach after teardown reports cancel requested`() {
|
|
146
|
+
val registry = IndeRunStreamRegistry()
|
|
147
|
+
|
|
148
|
+
registry.open("s1")
|
|
149
|
+
registry.cancelAll("torn down")
|
|
150
|
+
|
|
151
|
+
// The entry is gone, so the run that arrives late must be cancelled by its caller.
|
|
152
|
+
assertEquals(AttachOutcome.CancelRequested(null), registry.attach("s1", run {}))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun `a job attached to a closed stream is cancelled immediately`() {
|
|
157
|
+
val registry = IndeRunStreamRegistry()
|
|
158
|
+
val job = Job()
|
|
159
|
+
|
|
160
|
+
registry.attachJob("never_opened", job)
|
|
161
|
+
assertTrue(job.isCancelled)
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/definitions.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PluginListenerHandle } from "@capacitor/core";
|
|
2
|
+
import type { IndeRunError, ModelPackage, StreamEvent, StreamRunHandle, TaskRequest, TaskResult } from "@independo/inderun-contracts";
|
|
2
3
|
export interface OpenAIProviderBootstrapOptions {
|
|
3
4
|
model: string;
|
|
4
5
|
endpointUrl?: string;
|
|
@@ -6,12 +7,185 @@ export interface OpenAIProviderBootstrapOptions {
|
|
|
6
7
|
authContextRef?: string;
|
|
7
8
|
timeoutMs?: number;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Bootstrap options for the browser-managed on-device provider (Chrome's Prompt API).
|
|
12
|
+
*
|
|
13
|
+
* **Web only**, and ignored on iOS and Android, which register their own on-device
|
|
14
|
+
* providers — Apple Foundation Models and ML Kit GenAI — from `configure()` regardless.
|
|
15
|
+
*
|
|
16
|
+
* The browser owns model availability, download and execution, so there is nothing to
|
|
17
|
+
* configure: registering it is the whole point, and it is what makes
|
|
18
|
+
* `constraints.privacy = "local_required"` routable in a browser. Mode 1 only — this
|
|
19
|
+
* provider does not stream, so a `local_required` *stream* on the web is still refused at
|
|
20
|
+
* routing time.
|
|
21
|
+
*
|
|
22
|
+
* The web SDK's `runtime` injection seam is deliberately not exposed: it is a function,
|
|
23
|
+
* and `configure()` has to survive a JSON bridge hop on native.
|
|
24
|
+
*/
|
|
25
|
+
export interface SystemModelProviderBootstrapOptions {
|
|
26
|
+
id?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Bootstrap options for the Web ONNX Runtime provider, for a developer-supplied local
|
|
31
|
+
* model.
|
|
32
|
+
*
|
|
33
|
+
* **Web only**, like `systemModel`, and Mode 1 only.
|
|
34
|
+
*
|
|
35
|
+
* Two caveats worth knowing before reaching for this. The consumer has to install the
|
|
36
|
+
* optional `@huggingface/transformers` peer dependency — this bridge does not declare it —
|
|
37
|
+
* and supply real model weights, because the web SDK's `runtime` seam is a function and so
|
|
38
|
+
* cannot cross the JSON bridge hop: only the default Transformers.js runtime is reachable
|
|
39
|
+
* from here, never the fixture runtime the upstream demos use offline. A registered
|
|
40
|
+
* provider that cannot load turns a clean routing refusal into a provider error, so
|
|
41
|
+
* register it only when the weights are actually there.
|
|
42
|
+
*/
|
|
43
|
+
export interface OnnxProviderBootstrapOptions {
|
|
44
|
+
id?: string;
|
|
45
|
+
modelPackage: ModelPackage;
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
}
|
|
9
48
|
export interface ConfigureOptions {
|
|
10
49
|
openAI?: OpenAIProviderBootstrapOptions;
|
|
50
|
+
/** Web only; native registers its own on-device provider. */
|
|
51
|
+
systemModel?: SystemModelProviderBootstrapOptions;
|
|
52
|
+
/** Web only; native registers its own on-device provider. */
|
|
53
|
+
onnx?: OnnxProviderBootstrapOptions;
|
|
11
54
|
allowDirectOpenAIEndpoint?: boolean;
|
|
12
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Bridge-local correlation id for one streaming run.
|
|
58
|
+
*
|
|
59
|
+
* Not a contract field, and never placed inside the `TaskRequest`: `runId` stays the
|
|
60
|
+
* engine's to mint. `startStream` resolves only *after* the engine has minted one, so
|
|
61
|
+
* keying the JS-side event sink on `runId` would leave a window in which native can
|
|
62
|
+
* emit events for a run JS has not yet heard of. The `streamId` is generated before
|
|
63
|
+
* the call instead, which lets the sink be bound first and makes `cancel()`
|
|
64
|
+
* well-defined even before a handle exists.
|
|
65
|
+
*/
|
|
66
|
+
export interface StartStreamOptions {
|
|
67
|
+
streamId: string;
|
|
68
|
+
request: TaskRequest;
|
|
69
|
+
}
|
|
70
|
+
export interface CancelStreamOptions {
|
|
71
|
+
streamId: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
}
|
|
74
|
+
/** Payload of `"indeRunStreamEvent"`. `event` is the canonical, unmodified `StreamEvent`. */
|
|
75
|
+
export interface StreamEventNotification {
|
|
76
|
+
streamId: string;
|
|
77
|
+
event: StreamEvent;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Payload of `"indeRunStreamError"`: a failure of the *bridge*, not an outcome of the
|
|
81
|
+
* run. A run that fails ends in a terminal event with `payload.outcome === "error"`,
|
|
82
|
+
* delivered through `"indeRunStreamEvent"` like any other event.
|
|
83
|
+
*/
|
|
84
|
+
export interface StreamErrorNotification {
|
|
85
|
+
streamId: string;
|
|
86
|
+
error: IndeRunError;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Structurally identical to `StreamRun` in `@independo/inderun-web`, so bridge and
|
|
90
|
+
* direct-SDK consumers read the same. `events` terminates in exactly one terminal
|
|
91
|
+
* event and is single-use; `cancel()` is idempotent.
|
|
92
|
+
*/
|
|
93
|
+
export interface StreamRun {
|
|
94
|
+
handle: StreamRunHandle;
|
|
95
|
+
events: AsyncIterable<StreamEvent>;
|
|
96
|
+
cancel(reason?: string): void;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A provider's static capability declaration.
|
|
100
|
+
*
|
|
101
|
+
* Declared here rather than imported, because — unlike `TaskRequest`, `StreamEvent` and
|
|
102
|
+
* the rest — this shape is *not* a generated contract. There is no schema for it in
|
|
103
|
+
* `@independo/inderun-contracts` and no validator; each SDK declares it independently
|
|
104
|
+
* (`core/provider.d.ts` in `@independo/inderun-web`, `IndeRunCore/Provider.swift`,
|
|
105
|
+
* `core/Provider.kt`), and this is the fourth copy. Importing it from
|
|
106
|
+
* `@independo/inderun-web` is not an option either: that would make the native
|
|
107
|
+
* platforms' plugin contract depend on the web SDK.
|
|
108
|
+
*
|
|
109
|
+
* The drift guard is in `web.ts`, which returns the web SDK's value into this type
|
|
110
|
+
* uncast — so `tsc` proves the shapes still match on every build, the same way the
|
|
111
|
+
* `StreamRun` mirror below is checked. See the upstream follow-up asking for a schema.
|
|
112
|
+
*/
|
|
113
|
+
export interface ProviderDescriptor {
|
|
114
|
+
id: string;
|
|
115
|
+
type: "local" | "edge" | "cloud";
|
|
116
|
+
transport: "in_process" | "system_service" | "http" | "sse" | "realtime";
|
|
117
|
+
streamingStyle?: "tokens" | "chunks" | "snapshots";
|
|
118
|
+
supports: {
|
|
119
|
+
run: boolean;
|
|
120
|
+
streaming: boolean;
|
|
121
|
+
realtime: boolean;
|
|
122
|
+
tools: boolean;
|
|
123
|
+
reasoningEvents: boolean;
|
|
124
|
+
structuredOutput: boolean;
|
|
125
|
+
multimodal: boolean;
|
|
126
|
+
};
|
|
127
|
+
cancel: "hard" | "soft" | "none";
|
|
128
|
+
tasks: string[];
|
|
129
|
+
limits?: {
|
|
130
|
+
maxInputTokens?: number;
|
|
131
|
+
maxOutputTokens?: number;
|
|
132
|
+
maxImageBytes?: number;
|
|
133
|
+
maxAudioSeconds?: number;
|
|
134
|
+
};
|
|
135
|
+
privacy?: {
|
|
136
|
+
dataLeavesDevice: boolean;
|
|
137
|
+
regions?: string[];
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A provider's live availability, as of the moment `checkCapabilities()` was called.
|
|
142
|
+
*
|
|
143
|
+
* `streamingAvailable` and `cancellationAvailable` are **absent**, not `null`, when the
|
|
144
|
+
* runtime has nothing to say: absence means *inherit the static declaration*
|
|
145
|
+
* (`descriptor.supports.streaming`, and `descriptor.cancel !== "none"`). Both native
|
|
146
|
+
* encoders omit the key rather than emitting null for exactly that reason.
|
|
147
|
+
*/
|
|
148
|
+
export interface ProviderDynamicCapabilities {
|
|
149
|
+
available: boolean;
|
|
150
|
+
reason?: string;
|
|
151
|
+
streamingAvailable?: boolean;
|
|
152
|
+
streamingUnavailableReason?: string;
|
|
153
|
+
cancellationAvailable?: boolean;
|
|
154
|
+
}
|
|
155
|
+
/** One registered provider's identity, static declaration, and live availability. */
|
|
156
|
+
export interface ProviderCapabilitySnapshot {
|
|
157
|
+
providerId: string;
|
|
158
|
+
descriptor: ProviderDescriptor;
|
|
159
|
+
capabilities: ProviderDynamicCapabilities;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Envelope for `checkCapabilities()`. The array is wrapped because a Capacitor plugin
|
|
163
|
+
* method must resolve an object on both native platforms — `PluginCall.resolve(JSObject)`
|
|
164
|
+
* and `call.resolve([String: Any])` cannot carry a top-level array. `IndeRunCapacitor`
|
|
165
|
+
* and `createIndeRunCapacitor()` unwrap it, so app code sees the same
|
|
166
|
+
* `ProviderCapabilitySnapshot[]` the three platform SDKs return.
|
|
167
|
+
*/
|
|
168
|
+
export interface CheckCapabilitiesResult {
|
|
169
|
+
providers: ProviderCapabilitySnapshot[];
|
|
170
|
+
}
|
|
13
171
|
export interface IndeRunCapacitorPlugin {
|
|
14
172
|
configure(options?: ConfigureOptions): Promise<void>;
|
|
15
173
|
run(request: TaskRequest): Promise<TaskResult>;
|
|
174
|
+
/**
|
|
175
|
+
* Live snapshot of every registered provider, without executing a task. Availability
|
|
176
|
+
* changes between calls — a local model can unload, cloud credentials can expire — so
|
|
177
|
+
* do not cache it across a `run()` or `stream()`.
|
|
178
|
+
*/
|
|
179
|
+
checkCapabilities(): Promise<CheckCapabilitiesResult>;
|
|
180
|
+
/**
|
|
181
|
+
* Unlike `run(request)`, which passes the request at the options root, this nests it
|
|
182
|
+
* under `request` so the envelope can also carry `streamId`. Both native decode sites
|
|
183
|
+
* carry a matching comment.
|
|
184
|
+
*/
|
|
185
|
+
startStream(options: StartStreamOptions): Promise<StreamRunHandle>;
|
|
186
|
+
cancelStream(options: CancelStreamOptions): Promise<void>;
|
|
187
|
+
addListener(eventName: "indeRunStreamEvent", listenerFunc: (notification: StreamEventNotification) => void): Promise<PluginListenerHandle>;
|
|
188
|
+
addListener(eventName: "indeRunStreamError", listenerFunc: (notification: StreamErrorNotification) => void): Promise<PluginListenerHandle>;
|
|
189
|
+
removeAllListeners(): Promise<void>;
|
|
16
190
|
}
|
|
17
191
|
//# sourceMappingURL=definitions.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../src/definitions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../src/definitions.ts"],"names":[],"mappings":"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"}
|