@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
|
@@ -8,9 +8,17 @@ import app.independo.inderun.contracts.IndeRunErrorClass
|
|
|
8
8
|
import app.independo.inderun.contracts.Message
|
|
9
9
|
import app.independo.inderun.contracts.MessageRole
|
|
10
10
|
import app.independo.inderun.contracts.OptimizeFor
|
|
11
|
+
import app.independo.inderun.contracts.Outcome
|
|
11
12
|
import app.independo.inderun.contracts.OutputType
|
|
13
|
+
import app.independo.inderun.contracts.Payload
|
|
14
|
+
import app.independo.inderun.contracts.PayloadError
|
|
15
|
+
import app.independo.inderun.contracts.PayloadTelemetry
|
|
16
|
+
import app.independo.inderun.contracts.PayloadUsage
|
|
17
|
+
import app.independo.inderun.contracts.Phase
|
|
12
18
|
import app.independo.inderun.contracts.PrivacyEnum
|
|
13
19
|
import app.independo.inderun.contracts.SchemaVersion
|
|
20
|
+
import app.independo.inderun.contracts.StreamEvent
|
|
21
|
+
import app.independo.inderun.contracts.StreamRunHandle
|
|
14
22
|
import app.independo.inderun.contracts.TaskKind
|
|
15
23
|
import app.independo.inderun.contracts.TaskRequest
|
|
16
24
|
import app.independo.inderun.contracts.TaskRequestConstraints
|
|
@@ -20,7 +28,9 @@ import app.independo.inderun.contracts.TaskRequestTelemetry
|
|
|
20
28
|
import app.independo.inderun.contracts.TaskResult
|
|
21
29
|
import app.independo.inderun.contracts.TaskResultTelemetry
|
|
22
30
|
import app.independo.inderun.contracts.TelemetryLevel
|
|
23
|
-
import app.independo.inderun.
|
|
31
|
+
import app.independo.inderun.core.ProviderCapabilitySnapshot
|
|
32
|
+
import app.independo.inderun.core.ProviderDescriptor
|
|
33
|
+
import app.independo.inderun.core.ProviderDynamicCapabilities
|
|
24
34
|
import com.getcapacitor.JSArray
|
|
25
35
|
import com.getcapacitor.JSObject
|
|
26
36
|
import org.json.JSONArray
|
|
@@ -106,6 +116,198 @@ object IndeRunSerializer {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Wire encoding for `checkCapabilities()`.
|
|
121
|
+
*
|
|
122
|
+
* Note the enums are written with `.name`, which is the opposite of what
|
|
123
|
+
* [encodeOutcome] and [encodePhase] below have to do. Those contract enums are
|
|
124
|
+
* generated without raw values, so their Kotlin constant names are camelCase and the
|
|
125
|
+
* snake_case wire spelling has to be written by hand. `ProviderDescriptor`'s enums are
|
|
126
|
+
* declared with the wire spelling *as* the constant name (`in_process`,
|
|
127
|
+
* `system_service`), so `.name` already is the wire value — and writing them by hand
|
|
128
|
+
* here would be the thing that eventually drifts.
|
|
129
|
+
*
|
|
130
|
+
* The array is wrapped in a `providers` object because a Capacitor `PluginCall` cannot
|
|
131
|
+
* resolve a top-level array. `IndeRunCapacitor` unwraps it on the JS side.
|
|
132
|
+
*/
|
|
133
|
+
fun encodeCapabilitySnapshots(snapshots: List<ProviderCapabilitySnapshot>): JSObject {
|
|
134
|
+
return JSObject().apply {
|
|
135
|
+
put(
|
|
136
|
+
"providers",
|
|
137
|
+
JSArray().apply {
|
|
138
|
+
snapshots.forEach { snapshot ->
|
|
139
|
+
put(
|
|
140
|
+
JSObject().apply {
|
|
141
|
+
put("providerId", snapshot.providerId)
|
|
142
|
+
put("descriptor", encodeProviderDescriptor(snapshot.descriptor))
|
|
143
|
+
put("capabilities", encodeProviderDynamicCapabilities(snapshot.capabilities))
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun encodeProviderDescriptor(descriptor: ProviderDescriptor): JSObject {
|
|
153
|
+
return JSObject().apply {
|
|
154
|
+
put("id", descriptor.id)
|
|
155
|
+
put("type", descriptor.type.name)
|
|
156
|
+
put("transport", descriptor.transport.name)
|
|
157
|
+
descriptor.streamingStyle?.let { put("streamingStyle", it.name) }
|
|
158
|
+
put(
|
|
159
|
+
"supports",
|
|
160
|
+
JSObject().apply {
|
|
161
|
+
put("run", descriptor.supports.run)
|
|
162
|
+
put("streaming", descriptor.supports.streaming)
|
|
163
|
+
put("realtime", descriptor.supports.realtime)
|
|
164
|
+
put("tools", descriptor.supports.tools)
|
|
165
|
+
put("reasoningEvents", descriptor.supports.reasoningEvents)
|
|
166
|
+
put("structuredOutput", descriptor.supports.structuredOutput)
|
|
167
|
+
put("multimodal", descriptor.supports.multimodal)
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
put("cancel", descriptor.cancel.name)
|
|
171
|
+
put("tasks", JSArray().apply { descriptor.tasks.forEach { put(it) } })
|
|
172
|
+
descriptor.limits?.let { limits ->
|
|
173
|
+
put(
|
|
174
|
+
"limits",
|
|
175
|
+
JSObject().apply {
|
|
176
|
+
limits.maxInputTokens?.let { put("maxInputTokens", it) }
|
|
177
|
+
limits.maxOutputTokens?.let { put("maxOutputTokens", it) }
|
|
178
|
+
limits.maxImageBytes?.let { put("maxImageBytes", it) }
|
|
179
|
+
limits.maxAudioSeconds?.let { put("maxAudioSeconds", it) }
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
descriptor.privacy?.let { privacy ->
|
|
184
|
+
put(
|
|
185
|
+
"privacy",
|
|
186
|
+
JSObject().apply {
|
|
187
|
+
put("dataLeavesDevice", privacy.dataLeavesDevice)
|
|
188
|
+
privacy.regions?.let { regions ->
|
|
189
|
+
put("regions", JSArray().apply { regions.forEach { put(it) } })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The three nullable flags are **omitted** rather than written as null. Absence is the
|
|
199
|
+
* contract's "inherit the static declaration" state — `descriptor.supports.streaming`
|
|
200
|
+
* for streaming, `descriptor.cancel != none` for cancellation — and a null would be a
|
|
201
|
+
* third state no consumer has.
|
|
202
|
+
*/
|
|
203
|
+
private fun encodeProviderDynamicCapabilities(capabilities: ProviderDynamicCapabilities): JSObject {
|
|
204
|
+
return JSObject().apply {
|
|
205
|
+
put("available", capabilities.available)
|
|
206
|
+
capabilities.reason?.let { put("reason", it) }
|
|
207
|
+
capabilities.streamingAvailable?.let { put("streamingAvailable", it) }
|
|
208
|
+
capabilities.streamingUnavailableReason?.let { put("streamingUnavailableReason", it) }
|
|
209
|
+
capabilities.cancellationAvailable?.let { put("cancellationAvailable", it) }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
fun encodeStreamRunHandle(handle: StreamRunHandle): JSObject {
|
|
214
|
+
return JSObject().apply {
|
|
215
|
+
put("schemaVersion", handle.schemaVersion.rawValue)
|
|
216
|
+
put("runId", handle.runId)
|
|
217
|
+
put("startedAt", handle.startedAt)
|
|
218
|
+
handle.providerId?.let { put("providerId", it) }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Note what is deliberately *not* here: a `when` over [StreamEvent.type].
|
|
224
|
+
*
|
|
225
|
+
* The inbound parsers in this file throw on an unrecognized enum value, and that is
|
|
226
|
+
* right for them — those are closed contract enums on a request, where an unknown
|
|
227
|
+
* value really is an invalid request. `type` is the opposite: stream-event.schema.json
|
|
228
|
+
* closes its union with an explicit catch-all branch, and contracts/README.md requires
|
|
229
|
+
* SDKs to treat an unrecognized type as ignore-or-pass-through rather than an error, so
|
|
230
|
+
* that an additive minor revision does not break older consumers. Copying the
|
|
231
|
+
* throw-on-unknown pattern here would turn the one mechanism protecting forward
|
|
232
|
+
* compatibility into a guaranteed hard failure at the bridge hop. The type crosses as
|
|
233
|
+
* the string it is.
|
|
234
|
+
*/
|
|
235
|
+
fun encodeStreamEvent(event: StreamEvent): JSObject {
|
|
236
|
+
return JSObject().apply {
|
|
237
|
+
put("schemaVersion", event.schemaVersion.rawValue)
|
|
238
|
+
put("runId", event.runId)
|
|
239
|
+
put("sequence", event.sequence)
|
|
240
|
+
put("timestamp", event.timestamp)
|
|
241
|
+
put("type", event.type)
|
|
242
|
+
event.payload?.let { put("payload", encodeStreamPayload(it)) }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* quicktype flattens the event union, so [Payload] carries every branch's fields as
|
|
248
|
+
* optionals. Emitting only the non-null ones is what reconstitutes the correct branch
|
|
249
|
+
* on the JS side: each branch's `outcome` discriminator plus its required peer field
|
|
250
|
+
* (finalText / error / partialText) is exactly the set that is populated.
|
|
251
|
+
*/
|
|
252
|
+
private fun encodeStreamPayload(payload: Payload): JSObject {
|
|
253
|
+
return JSObject().apply {
|
|
254
|
+
payload.text?.let { put("text", it) }
|
|
255
|
+
payload.phase?.let { put("phase", phaseValue(it)) }
|
|
256
|
+
payload.finalText?.let { put("finalText", it) }
|
|
257
|
+
payload.finishReason?.let { put("finishReason", it.rawValue) }
|
|
258
|
+
payload.outcome?.let { put("outcome", outcomeValue(it)) }
|
|
259
|
+
payload.runId?.let { put("runId", it) }
|
|
260
|
+
payload.schemaVersion?.let { put("schemaVersion", it.rawValue) }
|
|
261
|
+
payload.telemetry?.let { put("telemetry", encodeStreamPayloadTelemetry(it)) }
|
|
262
|
+
payload.usage?.let { put("usage", encodeStreamPayloadUsage(it)) }
|
|
263
|
+
payload.error?.let { put("error", encodeStreamPayloadError(it)) }
|
|
264
|
+
payload.partialText?.let { put("partialText", it) }
|
|
265
|
+
payload.reason?.let { put("reason", it) }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private fun encodeStreamPayloadError(error: PayloadError): JSObject {
|
|
270
|
+
return JSObject().apply {
|
|
271
|
+
put("schemaVersion", error.schemaVersion.rawValue)
|
|
272
|
+
put("errorClass", error.errorClass.rawValue)
|
|
273
|
+
put("message", error.message)
|
|
274
|
+
error.providerId?.let { put("providerId", it) }
|
|
275
|
+
error.retryable?.let { put("retryable", it) }
|
|
276
|
+
error.retryAfterMs?.let { put("retryAfterMs", it) }
|
|
277
|
+
error.details?.let { put("details", JSONObject(it)) }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private fun encodeStreamPayloadTelemetry(telemetry: PayloadTelemetry): JSObject {
|
|
282
|
+
return JSObject().apply {
|
|
283
|
+
put("providerUsed", telemetry.providerUsed)
|
|
284
|
+
put("totalMs", telemetry.totalMs)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun encodeStreamPayloadUsage(usage: PayloadUsage): JSObject {
|
|
289
|
+
return JSObject().apply {
|
|
290
|
+
usage.inputTokens?.let { put("inputTokens", it) }
|
|
291
|
+
usage.outputTokens?.let { put("outputTokens", it) }
|
|
292
|
+
usage.totalTokens?.let { put("totalTokens", it) }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Outcome and Phase are generated without a rawValue, unlike every other contract enum
|
|
297
|
+
// here, so the wire strings have to be written out. Both `when`s are exhaustive with no
|
|
298
|
+
// `else`: if a regeneration ever adds a constant, this must fail to compile rather than
|
|
299
|
+
// silently serialize the wrong discriminator.
|
|
300
|
+
private fun outcomeValue(outcome: Outcome): String = when (outcome) {
|
|
301
|
+
Outcome.Completed -> "completed"
|
|
302
|
+
Outcome.Error -> "error"
|
|
303
|
+
Outcome.Cancelled -> "cancelled"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun phaseValue(phase: Phase): String = when (phase) {
|
|
307
|
+
Phase.ProviderSelected -> "provider_selected"
|
|
308
|
+
Phase.Started -> "started"
|
|
309
|
+
}
|
|
310
|
+
|
|
109
311
|
private fun parseOpenAIOptions(json: JSONObject): OpenAIProviderBootstrapOptions {
|
|
110
312
|
return OpenAIProviderBootstrapOptions(
|
|
111
313
|
model = json.getString("model"),
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package app.independo.inderun.capacitor
|
|
2
|
+
|
|
3
|
+
import app.independo.inderun.core.StreamRun
|
|
4
|
+
import kotlinx.coroutines.Job
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The outcome of attaching a freshly started run to its reserved id.
|
|
8
|
+
*/
|
|
9
|
+
internal sealed interface AttachOutcome {
|
|
10
|
+
data object Attached : AttachOutcome
|
|
11
|
+
|
|
12
|
+
/** A cancel arrived before the run did; the caller must apply it. */
|
|
13
|
+
data class CancelRequested(val reason: String?) : AttachOutcome
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tracks the streaming runs the plugin is currently collecting, keyed by the
|
|
18
|
+
* bridge-local `streamId`.
|
|
19
|
+
*
|
|
20
|
+
* Two things make this more than a map. A cancel can arrive before the run it refers
|
|
21
|
+
* to exists — `startStream` has to reach route selection first — so a cancel in that
|
|
22
|
+
* window is recorded and applied on attach. And cancelling a run is *not* cancelling
|
|
23
|
+
* the collecting job: the engine answers a cancel with its one `cancelled` terminal
|
|
24
|
+
* event, which the collector still has to deliver. Job cancellation is reserved for
|
|
25
|
+
* teardown, where the webview is going away and nobody is left to receive a terminal.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately free of any Capacitor dependency, so it is exercised by plain JVM unit
|
|
28
|
+
* tests.
|
|
29
|
+
*/
|
|
30
|
+
internal class IndeRunStreamRegistry {
|
|
31
|
+
private class Entry {
|
|
32
|
+
var run: StreamRun? = null
|
|
33
|
+
var job: Job? = null
|
|
34
|
+
var cancelRequested: Boolean = false
|
|
35
|
+
var cancelReason: String? = null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private val lock = Any()
|
|
39
|
+
private val entries = mutableMapOf<String, Entry>()
|
|
40
|
+
|
|
41
|
+
val activeCount: Int
|
|
42
|
+
get() = synchronized(lock) { entries.size }
|
|
43
|
+
|
|
44
|
+
/** Reserves the id so a cancel arriving before the run is recorded, not dropped. */
|
|
45
|
+
fun open(streamId: String) {
|
|
46
|
+
synchronized(lock) {
|
|
47
|
+
entries.getOrPut(streamId) { Entry() }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fun attach(streamId: String, run: StreamRun): AttachOutcome {
|
|
52
|
+
synchronized(lock) {
|
|
53
|
+
// Torn down while the engine was still selecting a route.
|
|
54
|
+
val entry = entries[streamId] ?: return AttachOutcome.CancelRequested(null)
|
|
55
|
+
if (entry.cancelRequested) {
|
|
56
|
+
return AttachOutcome.CancelRequested(entry.cancelReason)
|
|
57
|
+
}
|
|
58
|
+
entry.run = run
|
|
59
|
+
return AttachOutcome.Attached
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fun attachJob(streamId: String, job: Job) {
|
|
64
|
+
val orphaned = synchronized(lock) {
|
|
65
|
+
val entry = entries[streamId]
|
|
66
|
+
if (entry == null) {
|
|
67
|
+
true
|
|
68
|
+
} else {
|
|
69
|
+
entry.job = job
|
|
70
|
+
false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (orphaned) {
|
|
74
|
+
job.cancel()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cancels the run if it has attached, otherwise records the request for attach. An
|
|
80
|
+
* unknown id — already finished, or never started — is a silent no-op, which is what
|
|
81
|
+
* makes cancelling after the terminal harmless.
|
|
82
|
+
*/
|
|
83
|
+
fun requestCancel(streamId: String, reason: String?) {
|
|
84
|
+
val run = synchronized(lock) {
|
|
85
|
+
val entry = entries[streamId] ?: return
|
|
86
|
+
if (entry.cancelRequested) return
|
|
87
|
+
entry.cancelRequested = true
|
|
88
|
+
entry.cancelReason = reason
|
|
89
|
+
entry.run
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Never under the lock: cancel reaches into engine code.
|
|
93
|
+
run?.cancel(reason)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun close(streamId: String) {
|
|
97
|
+
synchronized(lock) {
|
|
98
|
+
entries.remove(streamId)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Teardown. Cancels each run first so providers unwind, then the collecting jobs,
|
|
104
|
+
* which at this point have nobody left to deliver to.
|
|
105
|
+
*/
|
|
106
|
+
fun cancelAll(reason: String?) {
|
|
107
|
+
val draining = synchronized(lock) {
|
|
108
|
+
val copy = entries.values.toList()
|
|
109
|
+
entries.clear()
|
|
110
|
+
copy
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (entry in draining) {
|
|
114
|
+
entry.run?.cancel(reason)
|
|
115
|
+
entry.job?.cancel()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
package app.independo.inderun.capacitor
|
|
2
|
+
|
|
3
|
+
import app.independo.inderun.core.ProviderCapabilitySnapshot
|
|
4
|
+
import app.independo.inderun.core.ProviderDescriptor
|
|
5
|
+
import app.independo.inderun.core.ProviderDynamicCapabilities
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertFalse
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
|
|
11
|
+
class IndeRunSerializerCapabilitiesTest {
|
|
12
|
+
|
|
13
|
+
private fun snapshot(
|
|
14
|
+
providerId: String,
|
|
15
|
+
type: ProviderDescriptor.ProviderType,
|
|
16
|
+
transport: ProviderDescriptor.TransportType,
|
|
17
|
+
streamingStyle: ProviderDescriptor.StreamingStyle? = null,
|
|
18
|
+
streaming: Boolean,
|
|
19
|
+
cancel: ProviderDescriptor.CancelSemantics,
|
|
20
|
+
limits: ProviderDescriptor.ResourceLimits? = null,
|
|
21
|
+
privacy: ProviderDescriptor.PrivacyDescriptor? = null,
|
|
22
|
+
capabilities: ProviderDynamicCapabilities
|
|
23
|
+
): ProviderCapabilitySnapshot = ProviderCapabilitySnapshot(
|
|
24
|
+
providerId = providerId,
|
|
25
|
+
descriptor = ProviderDescriptor(
|
|
26
|
+
id = providerId,
|
|
27
|
+
type = type,
|
|
28
|
+
transport = transport,
|
|
29
|
+
streamingStyle = streamingStyle,
|
|
30
|
+
supports = ProviderDescriptor.SupportsCapabilities(
|
|
31
|
+
run = true,
|
|
32
|
+
streaming = streaming,
|
|
33
|
+
realtime = false,
|
|
34
|
+
tools = false,
|
|
35
|
+
reasoningEvents = false,
|
|
36
|
+
structuredOutput = false,
|
|
37
|
+
multimodal = false
|
|
38
|
+
),
|
|
39
|
+
cancel = cancel,
|
|
40
|
+
tasks = listOf("text_to_text"),
|
|
41
|
+
limits = limits,
|
|
42
|
+
privacy = privacy
|
|
43
|
+
),
|
|
44
|
+
capabilities = capabilities
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@Test
|
|
48
|
+
fun `encodeCapabilitySnapshots wraps the snapshots in a providers array`() {
|
|
49
|
+
val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
|
|
50
|
+
listOf(
|
|
51
|
+
snapshot(
|
|
52
|
+
providerId = "openai",
|
|
53
|
+
type = ProviderDescriptor.ProviderType.cloud,
|
|
54
|
+
transport = ProviderDescriptor.TransportType.http,
|
|
55
|
+
streamingStyle = ProviderDescriptor.StreamingStyle.tokens,
|
|
56
|
+
streaming = true,
|
|
57
|
+
cancel = ProviderDescriptor.CancelSemantics.hard,
|
|
58
|
+
limits = ProviderDescriptor.ResourceLimits(
|
|
59
|
+
maxInputTokens = 128_000,
|
|
60
|
+
maxOutputTokens = 4_096
|
|
61
|
+
),
|
|
62
|
+
privacy = ProviderDescriptor.PrivacyDescriptor(
|
|
63
|
+
dataLeavesDevice = true,
|
|
64
|
+
regions = listOf("us")
|
|
65
|
+
),
|
|
66
|
+
capabilities = ProviderDynamicCapabilities(available = true)
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
val providers = encoded.getJSONArray("providers")
|
|
72
|
+
assertEquals(1, providers.length())
|
|
73
|
+
|
|
74
|
+
val provider = providers.getJSONObject(0)
|
|
75
|
+
assertEquals("openai", provider.getString("providerId"))
|
|
76
|
+
|
|
77
|
+
val descriptor = provider.getJSONObject("descriptor")
|
|
78
|
+
assertEquals("openai", descriptor.getString("id"))
|
|
79
|
+
assertEquals("cloud", descriptor.getString("type"))
|
|
80
|
+
assertEquals("http", descriptor.getString("transport"))
|
|
81
|
+
assertEquals("tokens", descriptor.getString("streamingStyle"))
|
|
82
|
+
assertEquals("hard", descriptor.getString("cancel"))
|
|
83
|
+
assertEquals(1, descriptor.getJSONArray("tasks").length())
|
|
84
|
+
assertEquals("text_to_text", descriptor.getJSONArray("tasks").getString(0))
|
|
85
|
+
|
|
86
|
+
val supports = descriptor.getJSONObject("supports")
|
|
87
|
+
assertTrue(supports.getBoolean("run"))
|
|
88
|
+
assertTrue(supports.getBoolean("streaming"))
|
|
89
|
+
assertFalse(supports.getBoolean("realtime"))
|
|
90
|
+
assertFalse(supports.getBoolean("tools"))
|
|
91
|
+
assertFalse(supports.getBoolean("reasoningEvents"))
|
|
92
|
+
assertFalse(supports.getBoolean("structuredOutput"))
|
|
93
|
+
assertFalse(supports.getBoolean("multimodal"))
|
|
94
|
+
|
|
95
|
+
val limits = descriptor.getJSONObject("limits")
|
|
96
|
+
assertEquals(128_000, limits.getInt("maxInputTokens"))
|
|
97
|
+
assertEquals(4_096, limits.getInt("maxOutputTokens"))
|
|
98
|
+
assertFalse(limits.has("maxImageBytes"))
|
|
99
|
+
assertFalse(limits.has("maxAudioSeconds"))
|
|
100
|
+
|
|
101
|
+
val privacy = descriptor.getJSONObject("privacy")
|
|
102
|
+
assertTrue(privacy.getBoolean("dataLeavesDevice"))
|
|
103
|
+
assertEquals("us", privacy.getJSONArray("regions").getString(0))
|
|
104
|
+
|
|
105
|
+
assertTrue(provider.getJSONObject("capabilities").getBoolean("available"))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `in_process` and `system_service` are the two descriptor constants a future upstream
|
|
110
|
+
* rename to camelCase would silently corrupt on the wire, because the TypeScript union
|
|
111
|
+
* in src/definitions.ts spells them out. Asserting the literals is the guard.
|
|
112
|
+
*/
|
|
113
|
+
@Test
|
|
114
|
+
fun `encodeCapabilitySnapshots writes snake case transport constants verbatim`() {
|
|
115
|
+
val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
|
|
116
|
+
listOf(
|
|
117
|
+
snapshot(
|
|
118
|
+
providerId = "local.onnx.genai.android",
|
|
119
|
+
type = ProviderDescriptor.ProviderType.local,
|
|
120
|
+
transport = ProviderDescriptor.TransportType.in_process,
|
|
121
|
+
streaming = false,
|
|
122
|
+
cancel = ProviderDescriptor.CancelSemantics.soft,
|
|
123
|
+
capabilities = ProviderDynamicCapabilities(
|
|
124
|
+
available = false,
|
|
125
|
+
reason = "No model package configured."
|
|
126
|
+
)
|
|
127
|
+
),
|
|
128
|
+
snapshot(
|
|
129
|
+
providerId = "android.mlkit.genai",
|
|
130
|
+
type = ProviderDescriptor.ProviderType.local,
|
|
131
|
+
transport = ProviderDescriptor.TransportType.system_service,
|
|
132
|
+
streamingStyle = ProviderDescriptor.StreamingStyle.chunks,
|
|
133
|
+
streaming = true,
|
|
134
|
+
cancel = ProviderDescriptor.CancelSemantics.soft,
|
|
135
|
+
capabilities = ProviderDynamicCapabilities(available = true)
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
val providers = encoded.getJSONArray("providers")
|
|
141
|
+
assertEquals(
|
|
142
|
+
"in_process",
|
|
143
|
+
providers.getJSONObject(0).getJSONObject("descriptor").getString("transport")
|
|
144
|
+
)
|
|
145
|
+
assertEquals(
|
|
146
|
+
"system_service",
|
|
147
|
+
providers.getJSONObject(1).getJSONObject("descriptor").getString("transport")
|
|
148
|
+
)
|
|
149
|
+
assertEquals(
|
|
150
|
+
"chunks",
|
|
151
|
+
providers.getJSONObject(1).getJSONObject("descriptor").getString("streamingStyle")
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Absence is the contract's "inherit the static declaration" state, so the nullable
|
|
157
|
+
* flags must not reach the wire as nulls — a null would be a third state no consumer
|
|
158
|
+
* has. Same for the optional descriptor sub-objects.
|
|
159
|
+
*/
|
|
160
|
+
@Test
|
|
161
|
+
fun `encodeCapabilitySnapshots omits unset optional fields instead of writing null`() {
|
|
162
|
+
val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
|
|
163
|
+
listOf(
|
|
164
|
+
snapshot(
|
|
165
|
+
providerId = "android.mlkit.genai",
|
|
166
|
+
type = ProviderDescriptor.ProviderType.local,
|
|
167
|
+
transport = ProviderDescriptor.TransportType.system_service,
|
|
168
|
+
streaming = true,
|
|
169
|
+
cancel = ProviderDescriptor.CancelSemantics.soft,
|
|
170
|
+
capabilities = ProviderDynamicCapabilities(available = true)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
val provider = encoded.getJSONArray("providers").getJSONObject(0)
|
|
176
|
+
val descriptor = provider.getJSONObject("descriptor")
|
|
177
|
+
assertFalse(descriptor.has("streamingStyle"))
|
|
178
|
+
assertFalse(descriptor.has("limits"))
|
|
179
|
+
assertFalse(descriptor.has("privacy"))
|
|
180
|
+
|
|
181
|
+
val capabilities = provider.getJSONObject("capabilities")
|
|
182
|
+
assertFalse(capabilities.has("reason"))
|
|
183
|
+
assertFalse(capabilities.has("streamingAvailable"))
|
|
184
|
+
assertFalse(capabilities.has("streamingUnavailableReason"))
|
|
185
|
+
assertFalse(capabilities.has("cancellationAvailable"))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@Test
|
|
189
|
+
fun `encodeCapabilitySnapshots encodes streaming taken away at runtime`() {
|
|
190
|
+
val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
|
|
191
|
+
listOf(
|
|
192
|
+
snapshot(
|
|
193
|
+
providerId = "openai",
|
|
194
|
+
type = ProviderDescriptor.ProviderType.cloud,
|
|
195
|
+
transport = ProviderDescriptor.TransportType.http,
|
|
196
|
+
streamingStyle = ProviderDescriptor.StreamingStyle.tokens,
|
|
197
|
+
streaming = true,
|
|
198
|
+
cancel = ProviderDescriptor.CancelSemantics.hard,
|
|
199
|
+
capabilities = ProviderDynamicCapabilities(
|
|
200
|
+
available = true,
|
|
201
|
+
streamingAvailable = false,
|
|
202
|
+
streamingUnavailableReason = "Host has no streaming HTTP client.",
|
|
203
|
+
cancellationAvailable = true
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
val capabilities = encoded.getJSONArray("providers")
|
|
210
|
+
.getJSONObject(0)
|
|
211
|
+
.getJSONObject("capabilities")
|
|
212
|
+
assertTrue(capabilities.getBoolean("available"))
|
|
213
|
+
assertFalse(capabilities.getBoolean("streamingAvailable"))
|
|
214
|
+
assertEquals(
|
|
215
|
+
"Host has no streaming HTTP client.",
|
|
216
|
+
capabilities.getString("streamingUnavailableReason")
|
|
217
|
+
)
|
|
218
|
+
assertTrue(capabilities.getBoolean("cancellationAvailable"))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@Test
|
|
222
|
+
fun `encodeCapabilitySnapshots encodes an empty registry as an empty array`() {
|
|
223
|
+
val encoded = IndeRunSerializer.encodeCapabilitySnapshots(emptyList())
|
|
224
|
+
|
|
225
|
+
assertTrue(encoded.has("providers"))
|
|
226
|
+
assertEquals(0, encoded.getJSONArray("providers").length())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Every enum constant of every descriptor enum maps to the wire spelling the
|
|
231
|
+
* TypeScript unions declare. Exhaustive by construction: `values()` grows when
|
|
232
|
+
* upstream adds a constant, and a renamed one fails here rather than in a demo.
|
|
233
|
+
*/
|
|
234
|
+
@Test
|
|
235
|
+
fun `every descriptor enum constant maps to its declared wire value`() {
|
|
236
|
+
assertEquals(
|
|
237
|
+
listOf("local", "edge", "cloud"),
|
|
238
|
+
ProviderDescriptor.ProviderType.values().map { it.name }
|
|
239
|
+
)
|
|
240
|
+
assertEquals(
|
|
241
|
+
listOf("in_process", "system_service", "http", "sse", "realtime"),
|
|
242
|
+
ProviderDescriptor.TransportType.values().map { it.name }
|
|
243
|
+
)
|
|
244
|
+
assertEquals(
|
|
245
|
+
listOf("tokens", "chunks", "snapshots"),
|
|
246
|
+
ProviderDescriptor.StreamingStyle.values().map { it.name }
|
|
247
|
+
)
|
|
248
|
+
assertEquals(
|
|
249
|
+
listOf("hard", "soft", "none"),
|
|
250
|
+
ProviderDescriptor.CancelSemantics.values().map { it.name }
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|