@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.2
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 +6 -2
- package/README.md +97 -9
- package/android/build.gradle.kts +5 -5
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +54 -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 +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -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 +17 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +70 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +121 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -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 +18 -6
package/Package.swift
CHANGED
|
@@ -9,7 +9,7 @@ import PackageDescription
|
|
|
9
9
|
let package = Package(
|
|
10
10
|
name: "IndeRunCapacitor",
|
|
11
11
|
platforms: [
|
|
12
|
-
.iOS(.
|
|
12
|
+
.iOS(.v16),
|
|
13
13
|
.macOS(.v14)
|
|
14
14
|
],
|
|
15
15
|
products: [
|
|
@@ -20,7 +20,11 @@ let package = Package(
|
|
|
20
20
|
],
|
|
21
21
|
dependencies: [
|
|
22
22
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
|
|
23
|
-
|
|
23
|
+
// `exact:` rather than `from:` while tracking a prerelease: SwiftPM's range
|
|
24
|
+
// operators exclude prerelease versions, so `from: "0.3.0-dev.14"` would
|
|
25
|
+
// silently keep resolving 0.2.2 and fail on the missing stream(). Move back
|
|
26
|
+
// to `from: "0.3.0"` once the stable release is out.
|
|
27
|
+
.package(url: "https://github.com/independo-gmbh/inderun.git", exact: "0.3.0-dev.14")
|
|
24
28
|
],
|
|
25
29
|
targets: [
|
|
26
30
|
.target(
|
package/README.md
CHANGED
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center">Built and maintained by <a href="https://www.independo.app/">Independo</a>.</p>
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**On-device AI with cloud fallback, for Capacitor apps.**
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
Runs a prompt on the device when the device can — Apple Foundation Models on iOS, ML Kit GenAI
|
|
20
|
+
(Gemini Nano) on Android — and falls back to an OpenAI-compatible cloud endpoint when it cannot.
|
|
21
|
+
One API across iOS, Android and the web, with streaming and cancellation. MIT.
|
|
22
|
+
|
|
23
|
+
It is a thin bridge, not a second engine. The package delegates to the released
|
|
24
|
+
[IndeRun](https://github.com/independo-gmbh/inderun) platform SDKs instead of re-implementing
|
|
25
|
+
routing, provider logic, or normalized error handling:
|
|
21
26
|
|
|
22
27
|
- web: [`@independo/inderun-web`](https://www.npmjs.com/package/@independo/inderun-web) (npm)
|
|
23
28
|
- iOS: `IndeRun` Swift package (`github.com/independo-gmbh/inderun`, SwiftPM)
|
|
@@ -38,10 +43,15 @@ Then sync native projects with your normal Capacitor workflow.
|
|
|
38
43
|
|
|
39
44
|
| Platform | Minimum version |
|
|
40
45
|
|----------|------------------------|
|
|
41
|
-
| iOS |
|
|
46
|
+
| iOS | 16.0 |
|
|
42
47
|
| Android | API 26 (Android 8.0) |
|
|
43
48
|
| Web | Any modern browser |
|
|
44
49
|
|
|
50
|
+
The iOS floor comes from the IndeRun Swift package this bridge depends on, not from the bridge
|
|
51
|
+
itself. On-device execution needs more than the floor: Apple Foundation Models requires an Apple
|
|
52
|
+
Intelligence–capable device on iOS 26+, and ML Kit GenAI requires AICore / Gemini Nano support.
|
|
53
|
+
Availability is checked at runtime and the cloud provider serves the request when it is missing.
|
|
54
|
+
|
|
45
55
|
## Usage
|
|
46
56
|
|
|
47
57
|
```ts
|
|
@@ -63,31 +73,109 @@ const result = await inderun.run({
|
|
|
63
73
|
});
|
|
64
74
|
```
|
|
65
75
|
|
|
76
|
+
## Streaming (Mode 2)
|
|
77
|
+
|
|
78
|
+
`stream()` returns the same `StreamRun` shape the platform SDKs return directly: the
|
|
79
|
+
run's handle, its canonical event sequence, and a cancel hook.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const run = await inderun.stream({
|
|
83
|
+
schemaVersion: "1.0",
|
|
84
|
+
task: { kind: "text_to_text" },
|
|
85
|
+
prompt: "Explain routing in two sentences."
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
console.log(run.handle.runId); // available before the first event
|
|
89
|
+
|
|
90
|
+
let text = "";
|
|
91
|
+
for await (const event of run.events) {
|
|
92
|
+
switch (event.type) {
|
|
93
|
+
case "content_delta":
|
|
94
|
+
text += event.payload.text; // append
|
|
95
|
+
break;
|
|
96
|
+
case "content_snapshot":
|
|
97
|
+
text = event.payload.text ?? ""; // replace — an empty one retracts
|
|
98
|
+
break;
|
|
99
|
+
case "terminal":
|
|
100
|
+
// exactly one of "completed" | "error" | "cancelled"
|
|
101
|
+
console.log(event.payload.outcome);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
run.cancel("user navigated away"); // idempotent; a no-op after the terminal
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Event semantics, ordering, the terminal guarantees, cancellation and fallback are the engines'
|
|
110
|
+
contract, identical on every platform, and documented once in
|
|
111
|
+
[Streaming (Mode 2)](https://github.com/independo-gmbh/inderun/blob/dev/docs/streaming.md). Two
|
|
112
|
+
things are specific to reaching them through a bridge:
|
|
113
|
+
|
|
114
|
+
- **The bridge hop is not order-preserving.** That is why `sequence` rather than arrival is the
|
|
115
|
+
contract's ordering authority. `events` already yields strictly by it; if you attach your own
|
|
116
|
+
listener to `STREAM_EVENT_NAME` instead, ordering is yours to enforce.
|
|
117
|
+
- **A failed run is not a rejected promise**, and a bridge transport fault is a third thing again.
|
|
118
|
+
See [Error Handling](#error-handling).
|
|
119
|
+
|
|
66
120
|
## API
|
|
67
121
|
|
|
68
|
-
- `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` and memoizes it. Safe to call once at app startup.
|
|
69
|
-
-
|
|
122
|
+
- `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` or `stream()` and memoizes it. Safe to call once at app startup.
|
|
123
|
+
- `run(request)` — Mode 1. Resolves with the canonical IndeRun `TaskResult`.
|
|
124
|
+
- `stream(request)` — Mode 2. Resolves with a `StreamRun` (`handle`, `events`, `cancel`). `events` is single-use.
|
|
125
|
+
- The low-level plugin methods `configure(options)`, `startStream(options)` and `cancelStream(options)` are also exported, along with the listener event names `STREAM_EVENT_NAME` (`"indeRunStreamEvent"`) and `STREAM_ERROR_NAME` (`"indeRunStreamError"`).
|
|
126
|
+
|
|
127
|
+
> The two listener event names are **public contract**. Native emits exactly these, and an
|
|
128
|
+
> app may attach its own listener to them; renaming one is a breaking change.
|
|
70
129
|
|
|
71
130
|
The `IndeRunCapacitorPlugin`, `ConfigureOptions`, and `OpenAIProviderBootstrapOptions`
|
|
72
131
|
contracts — including the `openAI` bootstrap config and the web-only
|
|
73
132
|
`allowDirectOpenAIEndpoint` flag — are defined and documented in
|
|
74
|
-
`src/definitions.ts`.
|
|
133
|
+
`src/definitions.ts`.
|
|
134
|
+
|
|
135
|
+
Note one asymmetry in the low-level surface: `run(request)` passes the request at the
|
|
136
|
+
options root, while `startStream({ streamId, request })` nests it, because that envelope
|
|
137
|
+
also carries the bridge-local correlation id. `stream()` hides this.
|
|
75
138
|
|
|
76
139
|
## Platform Notes
|
|
77
140
|
|
|
78
141
|
- Web requires `openAI` registration because the current web SDK only has the OpenAI-compatible provider.
|
|
79
142
|
- iOS always registers the Apple on-device provider and optionally registers OpenAI when configured.
|
|
80
143
|
- Android always registers the ML Kit on-device provider and optionally registers OpenAI when configured.
|
|
81
|
-
- Keep credentials behind `authContextRef`.
|
|
144
|
+
- Keep credentials behind `authContextRef`. That keeps a secret out of the request payload and out
|
|
145
|
+
of source; it does not make a key safe to ship, since anything an installed app or a browser can
|
|
146
|
+
read, someone with that app or browser can read. For a key you own, put it behind a backend you
|
|
147
|
+
control and point `endpointUrl` at that — for browser apps, a same-origin proxy with
|
|
148
|
+
`auth: "none"`.
|
|
82
149
|
|
|
83
150
|
## Current Limitations
|
|
84
151
|
|
|
85
|
-
- Mode 1 `run()`
|
|
152
|
+
- Mode 1 `run()` and Mode 2 `stream()` are supported. Mode 3 sessions are not.
|
|
153
|
+
- **No backpressure across the bridge.** Native pushes events; a slow consumer buys memory,
|
|
154
|
+
not throttling, because events buffer in JS until they are read. Runs are finite and
|
|
155
|
+
terminal-bounded, and any drop or throttle policy would be behaviour — which belongs in
|
|
156
|
+
the engines, not in a bridge.
|
|
157
|
+
- Which providers can actually stream is decided upstream, not here; see the
|
|
158
|
+
[provider matrix](https://github.com/independo-gmbh/inderun/blob/main/docs/architecture/providers.md#provider-matrix).
|
|
159
|
+
- An unrecognized `StreamEvent.type` is passed through untouched rather than rejected, so a
|
|
160
|
+
consumer built against an older contract revision keeps working when a newer one adds an
|
|
161
|
+
event type. Ignore what you do not recognize.
|
|
86
162
|
- No plugin-level credential management API is exposed in this first cut.
|
|
87
163
|
- The bridge is intentionally thin; cloud provider bootstrap still has to come from the app.
|
|
88
164
|
|
|
89
165
|
## Error Handling
|
|
90
166
|
|
|
167
|
+
A streaming run has **three** distinct failure surfaces, and conflating them is the easiest
|
|
168
|
+
mistake to make:
|
|
169
|
+
|
|
170
|
+
| Surface | When | How it reaches you |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| Rejection | Request validation, or no streaming-capable provider | `stream()` rejects with an `IndeRunError` |
|
|
173
|
+
| Terminal `error` event | A provider failed, or the whole planned chain did | **Not** a rejection — `for await` completes normally and the last event is `terminal` with `payload.outcome === "error"` |
|
|
174
|
+
| Bridge fault | The transport lost or could not encode events | `events` throws an `Internal` `IndeRunError` |
|
|
175
|
+
|
|
176
|
+
So a run that fails *after starting* ends your loop normally. Branch on
|
|
177
|
+
`event.payload.outcome` to tell completion from failure from cancellation.
|
|
178
|
+
|
|
91
179
|
Errors thrown by `configure()` and `run()` conform to `IndeRunError` from
|
|
92
180
|
`@independo/inderun-contracts`; branch on `error.errorClass` (the shared error
|
|
93
181
|
taxonomy). The bridge unwraps the native error envelope before re-throwing, so
|
package/android/build.gradle.kts
CHANGED
|
@@ -37,11 +37,11 @@ dependencies {
|
|
|
37
37
|
testImplementation("com.capacitorjs:core:8.0.0")
|
|
38
38
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
|
39
39
|
implementation("androidx.core:core-ktx:1.17.0")
|
|
40
|
-
implementation("app.independo.inderun:inderun-contracts:0.
|
|
41
|
-
implementation("app.independo.inderun:inderun-core:0.
|
|
42
|
-
implementation("app.independo.inderun:inderun-kotlin:0.
|
|
43
|
-
implementation("app.independo.inderun:inderun-mlkit-providers:0.
|
|
44
|
-
implementation("app.independo.inderun:inderun-openai-providers:0.
|
|
40
|
+
implementation("app.independo.inderun:inderun-contracts:0.3.0-dev.14")
|
|
41
|
+
implementation("app.independo.inderun:inderun-core:0.3.0-dev.14")
|
|
42
|
+
implementation("app.independo.inderun:inderun-kotlin:0.3.0-dev.14")
|
|
43
|
+
implementation("app.independo.inderun:inderun-mlkit-providers:0.3.0-dev.14")
|
|
44
|
+
implementation("app.independo.inderun:inderun-openai-providers:0.3.0-dev.14")
|
|
45
45
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
|
46
46
|
|
|
47
47
|
testImplementation("junit:junit:4.13.2")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package app.independo.inderun.capacitor
|
|
2
2
|
|
|
3
|
+
import app.independo.inderun.contracts.IndeRunError
|
|
3
4
|
import app.independo.inderun.core.IndeRunException
|
|
4
5
|
import app.independo.inderun.core.ProviderRegistry
|
|
5
6
|
import app.independo.inderun.core.toIndeRunException
|
|
@@ -9,10 +10,12 @@ import app.independo.inderun.providers.openai.OpenAIAuthMode
|
|
|
9
10
|
import app.independo.inderun.providers.openai.OpenAIProvider
|
|
10
11
|
import app.independo.inderun.providers.openai.OpenAIProviderOptions
|
|
11
12
|
import app.independo.inderun.sdk.IndeRun
|
|
13
|
+
import com.getcapacitor.JSObject
|
|
12
14
|
import com.getcapacitor.Plugin
|
|
13
15
|
import com.getcapacitor.PluginCall
|
|
14
16
|
import com.getcapacitor.PluginMethod
|
|
15
17
|
import com.getcapacitor.annotation.CapacitorPlugin
|
|
18
|
+
import kotlinx.coroutines.CancellationException
|
|
16
19
|
import kotlinx.coroutines.CoroutineScope
|
|
17
20
|
import kotlinx.coroutines.Dispatchers
|
|
18
21
|
import kotlinx.coroutines.SupervisorJob
|
|
@@ -23,6 +26,7 @@ import kotlinx.coroutines.launch
|
|
|
23
26
|
class IndeRunCapacitorPlugin : Plugin() {
|
|
24
27
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
25
28
|
private var configuredRegistry: ProviderRegistry? = null
|
|
29
|
+
private val streams = IndeRunStreamRegistry()
|
|
26
30
|
|
|
27
31
|
@PluginMethod
|
|
28
32
|
fun configure(call: PluginCall) {
|
|
@@ -83,11 +87,126 @@ class IndeRunCapacitorPlugin : Plugin() {
|
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Resolves with the run handle. Only validation and route-selection failures reject;
|
|
92
|
+
* a provider failure, a cancellation, or completion all arrive as the single terminal
|
|
93
|
+
* event on `indeRunStreamEvent` — which is why [resolved] is tracked: a PluginCall
|
|
94
|
+
* must settle exactly once, and a failure after the handle has gone back is an event,
|
|
95
|
+
* not a rejection.
|
|
96
|
+
*
|
|
97
|
+
* `retainUntilConsumed` closes the listener-registration race from the native side: an
|
|
98
|
+
* event emitted before the JS listener attaches is retained and replayed, not lost.
|
|
99
|
+
*/
|
|
100
|
+
@PluginMethod
|
|
101
|
+
fun startStream(call: PluginCall) {
|
|
102
|
+
val streamId = call.getString("streamId")
|
|
103
|
+
if (streamId == null) {
|
|
104
|
+
rejectContractError(call, IllegalArgumentException("startStream requires a streamId."))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
// Unlike run(request), which takes the request at the options root, startStream
|
|
108
|
+
// nests it under `request` so the envelope can also carry the bridge-local streamId.
|
|
109
|
+
val requestJson = call.getObject("request")
|
|
110
|
+
if (requestJson == null) {
|
|
111
|
+
rejectContractError(call, IllegalArgumentException("startStream requires a request."))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reserved before the engine is reached, so a cancel arriving during route
|
|
116
|
+
// selection is recorded rather than dropped as an unknown id.
|
|
117
|
+
streams.open(streamId)
|
|
118
|
+
|
|
119
|
+
val job = scope.launch {
|
|
120
|
+
var resolved = false
|
|
121
|
+
try {
|
|
122
|
+
val registry = configuredRegistry
|
|
123
|
+
?: throw toIndeRunException(IllegalStateException("Capacitor IndeRun has not been configured. Configure providers before calling stream(request)."))
|
|
124
|
+
val request = IndeRunSerializer.parseTaskRequest(requestJson)
|
|
125
|
+
// IndeRun is stateless; new per call is intentional — registry is cached after configure().
|
|
126
|
+
val streamRun = IndeRun.initialize(context.applicationContext, registry).stream(request)
|
|
127
|
+
|
|
128
|
+
val outcome = streams.attach(streamId, streamRun)
|
|
129
|
+
if (outcome is AttachOutcome.CancelRequested) {
|
|
130
|
+
streamRun.cancel(outcome.reason)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
call.resolve(IndeRunSerializer.encodeStreamRunHandle(streamRun.handle))
|
|
134
|
+
resolved = true
|
|
135
|
+
|
|
136
|
+
// The Flow is cold and single-use; collecting it exactly once here is what
|
|
137
|
+
// drives the run.
|
|
138
|
+
streamRun.events.collect { event ->
|
|
139
|
+
val payload = JSObject()
|
|
140
|
+
payload.put("streamId", streamId)
|
|
141
|
+
payload.put("event", IndeRunSerializer.encodeStreamEvent(event))
|
|
142
|
+
notifyListeners("indeRunStreamEvent", payload, true)
|
|
143
|
+
}
|
|
144
|
+
} catch (error: CancellationException) {
|
|
145
|
+
throw error
|
|
146
|
+
} catch (error: Throwable) {
|
|
147
|
+
val contractError = contractErrorFor(error)
|
|
148
|
+
if (resolved) {
|
|
149
|
+
val payload = JSObject()
|
|
150
|
+
payload.put("streamId", streamId)
|
|
151
|
+
payload.put(
|
|
152
|
+
"error",
|
|
153
|
+
runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
|
|
154
|
+
)
|
|
155
|
+
notifyListeners("indeRunStreamError", payload, true)
|
|
156
|
+
} else {
|
|
157
|
+
call.reject(
|
|
158
|
+
contractError.message,
|
|
159
|
+
contractError.errorClass.rawValue,
|
|
160
|
+
null,
|
|
161
|
+
runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
streams.close(streamId)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
streams.attachJob(streamId, job)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves for an unknown or already-finished run: cancelling after the terminal is a
|
|
174
|
+
* no-op by contract, not an error.
|
|
175
|
+
*/
|
|
176
|
+
@PluginMethod
|
|
177
|
+
fun cancelStream(call: PluginCall) {
|
|
178
|
+
val streamId = call.getString("streamId")
|
|
179
|
+
if (streamId == null) {
|
|
180
|
+
rejectContractError(call, IllegalArgumentException("cancelStream requires a streamId."))
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
streams.requestCancel(streamId, call.getString("reason"))
|
|
185
|
+
call.resolve()
|
|
186
|
+
}
|
|
187
|
+
|
|
86
188
|
override fun handleOnDestroy() {
|
|
189
|
+
// Runs first, so providers unwind before the scope that collects them goes away.
|
|
190
|
+
streams.cancelAll("Capacitor plugin destroyed.")
|
|
87
191
|
scope.cancel()
|
|
88
192
|
super.handleOnDestroy()
|
|
89
193
|
}
|
|
90
194
|
|
|
195
|
+
private fun contractErrorFor(error: Throwable): IndeRunError = when (error) {
|
|
196
|
+
is IndeRunException -> error.toContractError()
|
|
197
|
+
else -> toIndeRunException(error).toContractError()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private fun rejectContractError(call: PluginCall, error: Throwable) {
|
|
201
|
+
val contractError = contractErrorFor(error)
|
|
202
|
+
call.reject(
|
|
203
|
+
contractError.message,
|
|
204
|
+
contractError.errorClass.rawValue,
|
|
205
|
+
null,
|
|
206
|
+
runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
91
210
|
private fun createRegistry(openAI: OpenAIProviderBootstrapOptions?): ProviderRegistry {
|
|
92
211
|
val registry = AndroidProviderRegistryFactory.makeDefaultRegistry(context.applicationContext)
|
|
93
212
|
|
|
@@ -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,6 @@ 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.contracts.Usage
|
|
24
31
|
import com.getcapacitor.JSArray
|
|
25
32
|
import com.getcapacitor.JSObject
|
|
26
33
|
import org.json.JSONArray
|
|
@@ -106,6 +113,104 @@ object IndeRunSerializer {
|
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
fun encodeStreamRunHandle(handle: StreamRunHandle): JSObject {
|
|
117
|
+
return JSObject().apply {
|
|
118
|
+
put("schemaVersion", handle.schemaVersion.rawValue)
|
|
119
|
+
put("runId", handle.runId)
|
|
120
|
+
put("startedAt", handle.startedAt)
|
|
121
|
+
handle.providerId?.let { put("providerId", it) }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Note what is deliberately *not* here: a `when` over [StreamEvent.type].
|
|
127
|
+
*
|
|
128
|
+
* The inbound parsers in this file throw on an unrecognized enum value, and that is
|
|
129
|
+
* right for them — those are closed contract enums on a request, where an unknown
|
|
130
|
+
* value really is an invalid request. `type` is the opposite: stream-event.schema.json
|
|
131
|
+
* closes its union with an explicit catch-all branch, and contracts/README.md requires
|
|
132
|
+
* SDKs to treat an unrecognized type as ignore-or-pass-through rather than an error, so
|
|
133
|
+
* that an additive minor revision does not break older consumers. Copying the
|
|
134
|
+
* throw-on-unknown pattern here would turn the one mechanism protecting forward
|
|
135
|
+
* compatibility into a guaranteed hard failure at the bridge hop. The type crosses as
|
|
136
|
+
* the string it is.
|
|
137
|
+
*/
|
|
138
|
+
fun encodeStreamEvent(event: StreamEvent): JSObject {
|
|
139
|
+
return JSObject().apply {
|
|
140
|
+
put("schemaVersion", event.schemaVersion.rawValue)
|
|
141
|
+
put("runId", event.runId)
|
|
142
|
+
put("sequence", event.sequence)
|
|
143
|
+
put("timestamp", event.timestamp)
|
|
144
|
+
put("type", event.type)
|
|
145
|
+
event.payload?.let { put("payload", encodeStreamPayload(it)) }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* quicktype flattens the event union, so [Payload] carries every branch's fields as
|
|
151
|
+
* optionals. Emitting only the non-null ones is what reconstitutes the correct branch
|
|
152
|
+
* on the JS side: each branch's `outcome` discriminator plus its required peer field
|
|
153
|
+
* (finalText / error / partialText) is exactly the set that is populated.
|
|
154
|
+
*/
|
|
155
|
+
private fun encodeStreamPayload(payload: Payload): JSObject {
|
|
156
|
+
return JSObject().apply {
|
|
157
|
+
payload.text?.let { put("text", it) }
|
|
158
|
+
payload.phase?.let { put("phase", phaseValue(it)) }
|
|
159
|
+
payload.finalText?.let { put("finalText", it) }
|
|
160
|
+
payload.finishReason?.let { put("finishReason", it.rawValue) }
|
|
161
|
+
payload.outcome?.let { put("outcome", outcomeValue(it)) }
|
|
162
|
+
payload.runId?.let { put("runId", it) }
|
|
163
|
+
payload.schemaVersion?.let { put("schemaVersion", it.rawValue) }
|
|
164
|
+
payload.telemetry?.let { put("telemetry", encodeStreamPayloadTelemetry(it)) }
|
|
165
|
+
payload.usage?.let { put("usage", encodeStreamPayloadUsage(it)) }
|
|
166
|
+
payload.error?.let { put("error", encodeStreamPayloadError(it)) }
|
|
167
|
+
payload.partialText?.let { put("partialText", it) }
|
|
168
|
+
payload.reason?.let { put("reason", it) }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun encodeStreamPayloadError(error: PayloadError): JSObject {
|
|
173
|
+
return JSObject().apply {
|
|
174
|
+
put("schemaVersion", error.schemaVersion.rawValue)
|
|
175
|
+
put("errorClass", error.errorClass.rawValue)
|
|
176
|
+
put("message", error.message)
|
|
177
|
+
error.providerId?.let { put("providerId", it) }
|
|
178
|
+
error.retryable?.let { put("retryable", it) }
|
|
179
|
+
error.retryAfterMs?.let { put("retryAfterMs", it) }
|
|
180
|
+
error.details?.let { put("details", JSONObject(it)) }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private fun encodeStreamPayloadTelemetry(telemetry: PayloadTelemetry): JSObject {
|
|
185
|
+
return JSObject().apply {
|
|
186
|
+
put("providerUsed", telemetry.providerUsed)
|
|
187
|
+
put("totalMs", telemetry.totalMs)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun encodeStreamPayloadUsage(usage: PayloadUsage): JSObject {
|
|
192
|
+
return JSObject().apply {
|
|
193
|
+
usage.inputTokens?.let { put("inputTokens", it) }
|
|
194
|
+
usage.outputTokens?.let { put("outputTokens", it) }
|
|
195
|
+
usage.totalTokens?.let { put("totalTokens", it) }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Outcome and Phase are generated without a rawValue, unlike every other contract enum
|
|
200
|
+
// here, so the wire strings have to be written out. Both `when`s are exhaustive with no
|
|
201
|
+
// `else`: if a regeneration ever adds a constant, this must fail to compile rather than
|
|
202
|
+
// silently serialize the wrong discriminator.
|
|
203
|
+
private fun outcomeValue(outcome: Outcome): String = when (outcome) {
|
|
204
|
+
Outcome.Completed -> "completed"
|
|
205
|
+
Outcome.Error -> "error"
|
|
206
|
+
Outcome.Cancelled -> "cancelled"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private fun phaseValue(phase: Phase): String = when (phase) {
|
|
210
|
+
Phase.ProviderSelected -> "provider_selected"
|
|
211
|
+
Phase.Started -> "started"
|
|
212
|
+
}
|
|
213
|
+
|
|
109
214
|
private fun parseOpenAIOptions(json: JSONObject): OpenAIProviderBootstrapOptions {
|
|
110
215
|
return OpenAIProviderBootstrapOptions(
|
|
111
216
|
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
|
+
}
|