@independo/inderun-web 0.3.0-dev.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/dist/core/engine.d.ts +11 -0
- package/dist/core/engine.d.ts.map +1 -1
- package/dist/core/engine.js +38 -42
- package/dist/core/route-planner.d.ts +16 -13
- package/dist/core/route-planner.d.ts.map +1 -1
- package/dist/core/route-planner.js +22 -19
- package/dist/core/router.d.ts +1 -39
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +12 -206
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -2
- package/dist/onnx.d.ts +21 -0
- package/dist/onnx.d.ts.map +1 -1
- package/dist/onnx.js +15 -0
- package/dist/openai.d.ts +7 -0
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +7 -0
- package/dist/system-model.d.ts +13 -0
- package/dist/system-model.d.ts.map +1 -1
- package/dist/system-model.js +13 -0
- package/package.json +22 -8
- package/src/core/browser-host.ts +339 -0
- package/src/core/engine.ts +540 -0
- package/src/core/errors.ts +220 -0
- package/src/core/event-gate.ts +71 -0
- package/src/core/generated/inderun-api.ts +19 -0
- package/src/core/host.ts +15 -0
- package/src/core/provider.ts +231 -0
- package/src/core/registry.ts +48 -0
- package/src/core/route-planner.ts +261 -0
- package/src/core/router.ts +149 -0
- package/src/core/sse.ts +134 -0
- package/src/core/telemetry.ts +20 -0
- package/src/index.ts +147 -0
- package/src/onnx.ts +57 -0
- package/src/openai.ts +21 -0
- package/src/providers/onnx/provider.ts +300 -0
- package/src/providers/onnx/runtime.ts +188 -0
- package/src/providers/onnx/transformers-runtime.ts +345 -0
- package/src/providers/openai/provider.ts +765 -0
- package/src/providers/system-model/chrome-runtime.ts +221 -0
- package/src/providers/system-model/provider.ts +260 -0
- package/src/providers/system-model/runtime.ts +202 -0
- package/src/system-model.ts +49 -0
- package/src/web-sdk.ts +114 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RoutePlan as SharedPlannerRoutePlan,
|
|
3
|
+
RoutePlannerInput as SharedPlannerInput,
|
|
4
|
+
TaskRequest
|
|
5
|
+
} from "@independo/inderun-contracts";
|
|
6
|
+
import type { HostServices } from "./host.js";
|
|
7
|
+
import type {
|
|
8
|
+
ProviderAdapter,
|
|
9
|
+
ProviderDescriptor,
|
|
10
|
+
ProviderDynamicCapabilities
|
|
11
|
+
} from "./provider.js";
|
|
12
|
+
|
|
13
|
+
export type { SharedPlannerInput, SharedPlannerRoutePlan };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Interaction mode the caller is requesting a route for. Passed to the shared
|
|
17
|
+
* planner, which rejects providers that cannot satisfy it.
|
|
18
|
+
*/
|
|
19
|
+
export type InteractionMode = NonNullable<SharedPlannerInput["interactionMode"]>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal shape of the shared route-core module (the WASM wrapper), used to
|
|
23
|
+
* plan routes via JSON in/JSON out. `initSharedCore` initializes the WASM
|
|
24
|
+
* bindings when present.
|
|
25
|
+
*/
|
|
26
|
+
export interface SharedPlannerModule {
|
|
27
|
+
initSharedCore?: () => Promise<void>;
|
|
28
|
+
planRouteJson: (inputJson: string) => string | Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coarse, privacy-safe reason the WASM planner produced no route plan. Surfaced
|
|
33
|
+
* on the resulting `Internal` routing error and in the `console.warn`, so an
|
|
34
|
+
* operator can tell a missing bundle chunk from a blocked instantiation.
|
|
35
|
+
*/
|
|
36
|
+
export type WasmUnavailableReason =
|
|
37
|
+
"import_failed" | "invalid_module_shape" | "init_failed" | "plan_failed";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result of a {@link RoutePlanner} attempt. `routePlan` is `null` when the
|
|
41
|
+
* planner could not produce a plan, and `unavailableReason` then narrows down
|
|
42
|
+
* why.
|
|
43
|
+
*/
|
|
44
|
+
export interface PlannerOutcome {
|
|
45
|
+
routePlan: SharedPlannerRoutePlan | null;
|
|
46
|
+
unavailableReason?: WasmUnavailableReason;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strategy that turns planner input into a deterministic route plan, or an
|
|
51
|
+
* "unavailable" outcome (which the router reports as a routing failure).
|
|
52
|
+
*/
|
|
53
|
+
export interface RoutePlanner {
|
|
54
|
+
planRoute(input: SharedPlannerInput): Promise<PlannerOutcome>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A provider paired with the descriptor/capability projection the shared route
|
|
59
|
+
* planner consumes.
|
|
60
|
+
*/
|
|
61
|
+
export interface ProviderRuntimeSnapshot {
|
|
62
|
+
provider: ProviderAdapter;
|
|
63
|
+
descriptor: SharedPlannerInput["providers"][number]["descriptor"];
|
|
64
|
+
capabilities: SharedPlannerInput["providers"][number]["capabilities"];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* {@link RoutePlanner} backed by the Rust route core compiled to WASM. The
|
|
69
|
+
* module specifier is a static literal (not configurable) so bundlers such as
|
|
70
|
+
* Vite/webpack/Rollup can statically resolve and chunk it — a variable
|
|
71
|
+
* specifier defeats bundler analysis and silently never loads in the browser
|
|
72
|
+
* (see issue #109). The module is imported lazily and memoized; if it cannot
|
|
73
|
+
* be loaded, `planRoute` resolves an "unavailable" outcome, which the engine
|
|
74
|
+
* turns into an `Internal` routing failure carrying the reason below. There is
|
|
75
|
+
* no second planner to degrade to — a failure here is a failure to route, not a
|
|
76
|
+
* quieter route — so this also logs once via `console.warn` to name the cause.
|
|
77
|
+
*/
|
|
78
|
+
export class WasmRoutePlanner implements RoutePlanner {
|
|
79
|
+
private modulePromise?: Promise<SharedPlannerModule | null>;
|
|
80
|
+
private loadFailure?: { reason: WasmUnavailableReason; error?: unknown };
|
|
81
|
+
private hasWarned = false;
|
|
82
|
+
|
|
83
|
+
async planRoute(input: SharedPlannerInput): Promise<PlannerOutcome> {
|
|
84
|
+
const module = await this.loadModule();
|
|
85
|
+
if (!module) {
|
|
86
|
+
// The load path only records why it failed; warning here covers both the
|
|
87
|
+
// first attempt and every later call served from the memoized promise.
|
|
88
|
+
const unavailableReason = this.loadFailure?.reason ?? "import_failed";
|
|
89
|
+
this.warnOnce(unavailableReason, this.loadFailure?.error);
|
|
90
|
+
return { routePlan: null, unavailableReason };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const json = JSON.stringify(input);
|
|
95
|
+
const result = await module.planRouteJson(json);
|
|
96
|
+
return { routePlan: JSON.parse(result) as SharedPlannerRoutePlan };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.warnOnce("plan_failed", error);
|
|
99
|
+
return { routePlan: null, unavailableReason: "plan_failed" };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async loadModule(): Promise<SharedPlannerModule | null> {
|
|
104
|
+
if (!this.modulePromise) {
|
|
105
|
+
this.modulePromise = this.importModule();
|
|
106
|
+
}
|
|
107
|
+
return this.modulePromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async importModule(): Promise<SharedPlannerModule | null> {
|
|
111
|
+
let mod: SharedPlannerModule;
|
|
112
|
+
try {
|
|
113
|
+
mod = (await import("@independo/inderun-route-core-wasm")) as SharedPlannerModule;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
this.loadFailure = { reason: "import_failed", error };
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof mod.planRouteJson !== "function") {
|
|
120
|
+
this.loadFailure = { reason: "invalid_module_shape" };
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (mod.initSharedCore) {
|
|
125
|
+
try {
|
|
126
|
+
await mod.initSharedCore();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.loadFailure = { reason: "init_failed", error };
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return mod;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private warnOnce(reason: WasmUnavailableReason, error?: unknown): void {
|
|
137
|
+
if (this.hasWarned) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.hasWarned = true;
|
|
141
|
+
console.warn(`[IndeRun] WASM route planner unavailable (${reason}); routing will fail.`, error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Describes each provider and evaluates its dynamic capabilities against the
|
|
147
|
+
* host.
|
|
148
|
+
*
|
|
149
|
+
* Registration order is passed through untouched: the shared planner ranks
|
|
150
|
+
* candidates itself and tie-breaks by provider id, so sorting here would only
|
|
151
|
+
* assert an ordering the planner is free to ignore.
|
|
152
|
+
*/
|
|
153
|
+
export async function collectProviderRuntimeSnapshots(
|
|
154
|
+
registryProviders: ProviderAdapter[],
|
|
155
|
+
hostServices: HostServices
|
|
156
|
+
): Promise<ProviderRuntimeSnapshot[]> {
|
|
157
|
+
return Promise.all(
|
|
158
|
+
registryProviders.map(async (provider) => {
|
|
159
|
+
const descriptor = provider.describe();
|
|
160
|
+
const capabilities = await provider.capabilities(hostServices);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
provider,
|
|
164
|
+
descriptor: toSharedPlannerDescriptor(descriptor),
|
|
165
|
+
capabilities: toSharedPlannerCapabilities(capabilities, descriptor, provider)
|
|
166
|
+
};
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Assembles the normalized {@link SharedPlannerInput} the shared route core
|
|
173
|
+
* consumes, applying default constraints/preferences and the current network
|
|
174
|
+
* state.
|
|
175
|
+
*/
|
|
176
|
+
export function buildSharedPlannerInput(
|
|
177
|
+
request: TaskRequest,
|
|
178
|
+
snapshots: ProviderRuntimeSnapshot[],
|
|
179
|
+
networkOnline: boolean,
|
|
180
|
+
interactionMode: InteractionMode = "run"
|
|
181
|
+
): SharedPlannerInput {
|
|
182
|
+
const constraints = request.constraints ?? {};
|
|
183
|
+
const preferences = request.preferences ?? {};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
task: {
|
|
187
|
+
kind: request.task.kind
|
|
188
|
+
},
|
|
189
|
+
interactionMode,
|
|
190
|
+
constraints: {
|
|
191
|
+
privacy: constraints.privacy ?? "cloud_allowed",
|
|
192
|
+
cloud: constraints.cloud ?? "allowed",
|
|
193
|
+
networkOnline,
|
|
194
|
+
...(constraints.timeoutMs !== undefined ? { timeoutMs: constraints.timeoutMs } : {})
|
|
195
|
+
},
|
|
196
|
+
preferences: {
|
|
197
|
+
optimizeFor: preferences.optimizeFor ?? "balanced"
|
|
198
|
+
},
|
|
199
|
+
providers: snapshots.map((snapshot) => ({
|
|
200
|
+
descriptor: snapshot.descriptor,
|
|
201
|
+
capabilities: snapshot.capabilities
|
|
202
|
+
}))
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toSharedPlannerDescriptor(
|
|
207
|
+
descriptor: ProviderDescriptor
|
|
208
|
+
): SharedPlannerInput["providers"][number]["descriptor"] {
|
|
209
|
+
return {
|
|
210
|
+
id: descriptor.id,
|
|
211
|
+
type: descriptor.type,
|
|
212
|
+
supports: {
|
|
213
|
+
run: descriptor.supports.run,
|
|
214
|
+
streaming: descriptor.supports.streaming
|
|
215
|
+
},
|
|
216
|
+
cancel: descriptor.cancel,
|
|
217
|
+
tasks: descriptor.tasks,
|
|
218
|
+
...(descriptor.privacy
|
|
219
|
+
? {
|
|
220
|
+
privacy: {
|
|
221
|
+
dataLeavesDevice: descriptor.privacy.dataLeavesDevice,
|
|
222
|
+
...(descriptor.privacy.regions ? { regions: descriptor.privacy.regions } : {})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
: {})
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Projects the dynamic capability snapshot for the planner.
|
|
231
|
+
*
|
|
232
|
+
* `stream()` being absent from the adapter is folded in here rather than
|
|
233
|
+
* filtered out downstream: an adapter that declares `supports.streaming` but
|
|
234
|
+
* never implemented the method is not streaming-capable, and saying so as a
|
|
235
|
+
* planner-visible reason is what lets the route explanation name the provider
|
|
236
|
+
* instead of silently dropping it.
|
|
237
|
+
*/
|
|
238
|
+
function toSharedPlannerCapabilities(
|
|
239
|
+
capabilities: ProviderDynamicCapabilities,
|
|
240
|
+
descriptor: ProviderDescriptor,
|
|
241
|
+
provider: ProviderAdapter
|
|
242
|
+
): SharedPlannerInput["providers"][number]["capabilities"] {
|
|
243
|
+
const declaresStreaming = capabilities.streamingAvailable ?? descriptor.supports.streaming;
|
|
244
|
+
const implementsStream = typeof provider.stream === "function";
|
|
245
|
+
const streamingAvailable = declaresStreaming && implementsStream;
|
|
246
|
+
const streamingUnavailableReason =
|
|
247
|
+
capabilities.streamingUnavailableReason ??
|
|
248
|
+
(declaresStreaming && !implementsStream
|
|
249
|
+
? `Provider '${descriptor.id}' declares streaming but does not implement stream().`
|
|
250
|
+
: undefined);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
available: capabilities.available,
|
|
254
|
+
...(capabilities.reason ? { reason: capabilities.reason } : {}),
|
|
255
|
+
streamingAvailable,
|
|
256
|
+
...(!streamingAvailable && streamingUnavailableReason ? { streamingUnavailableReason } : {}),
|
|
257
|
+
...(capabilities.cancellationAvailable !== undefined
|
|
258
|
+
? { cancellationAvailable: capabilities.cancellationAvailable }
|
|
259
|
+
: {})
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { TaskRequest } from "@independo/inderun-contracts";
|
|
2
|
+
import type { HostServices } from "./host.js";
|
|
3
|
+
import type { ProviderAdapter } from "./provider.js";
|
|
4
|
+
import type { ProviderRegistry } from "./registry.js";
|
|
5
|
+
import {
|
|
6
|
+
createCapabilityMismatch,
|
|
7
|
+
createInternal,
|
|
8
|
+
createOffline,
|
|
9
|
+
createUnavailable
|
|
10
|
+
} from "./errors.js";
|
|
11
|
+
import {
|
|
12
|
+
buildSharedPlannerInput,
|
|
13
|
+
collectProviderRuntimeSnapshots,
|
|
14
|
+
type InteractionMode,
|
|
15
|
+
type ProviderRuntimeSnapshot,
|
|
16
|
+
type RoutePlanner,
|
|
17
|
+
type SharedPlannerRoutePlan,
|
|
18
|
+
WasmRoutePlanner
|
|
19
|
+
} from "./route-planner.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Output structure from the Router containing the chosen provider and a route selection description.
|
|
23
|
+
*/
|
|
24
|
+
export interface RouteSelection {
|
|
25
|
+
/**
|
|
26
|
+
* The selected provider adapter to run.
|
|
27
|
+
*/
|
|
28
|
+
provider: ProviderAdapter;
|
|
29
|
+
/**
|
|
30
|
+
* Ordered fallback providers to try if the primary provider fails before producing a final result.
|
|
31
|
+
*/
|
|
32
|
+
fallbackProviders: ProviderAdapter[];
|
|
33
|
+
/**
|
|
34
|
+
* The shared route plan used to select this provider chain.
|
|
35
|
+
*/
|
|
36
|
+
routePlan: SharedPlannerRoutePlan;
|
|
37
|
+
/**
|
|
38
|
+
* Explanation detailing the selection decision. Useful for debugging and telemetry.
|
|
39
|
+
*/
|
|
40
|
+
explanation: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Routing engine module. Filters registered providers against task types, request constraints,
|
|
45
|
+
* and dynamic host capabilities (such as connectivity and battery/thermal constraints)
|
|
46
|
+
* to output deterministic execution pathways.
|
|
47
|
+
*/
|
|
48
|
+
export class Router {
|
|
49
|
+
constructor(
|
|
50
|
+
private registry: ProviderRegistry,
|
|
51
|
+
private planner: RoutePlanner = new WasmRoutePlanner()
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Selects an optimal and compatible execution provider based on the task request and host conditions.
|
|
56
|
+
* @param request - The canonical task request payload.
|
|
57
|
+
* @param hostServices - Host services containing network status and hardware indicators.
|
|
58
|
+
* @returns Resolves to the selected RouteSelection object.
|
|
59
|
+
* @throws {IndeRunException} Under the following routing failure rules:
|
|
60
|
+
* - `CapabilityMismatch` when `execution === 'on_device'` but no local provider is registered or available.
|
|
61
|
+
* - `Offline` when `execution === 'cloud'` but the device lacks internet connectivity.
|
|
62
|
+
* - `Unavailable` when `execution === 'cloud'` but no cloud provider is registered or functional.
|
|
63
|
+
*
|
|
64
|
+
* The requested `interactionMode` is a planning input, not a post-filter: the
|
|
65
|
+
* shared planner rejects providers that cannot satisfy it and says why, so the
|
|
66
|
+
* whole returned chain is mode-compatible.
|
|
67
|
+
*/
|
|
68
|
+
async selectRoute(
|
|
69
|
+
request: TaskRequest,
|
|
70
|
+
hostServices: HostServices,
|
|
71
|
+
interactionMode: InteractionMode = "run"
|
|
72
|
+
): Promise<RouteSelection> {
|
|
73
|
+
const online = await hostServices.connectivity.isOnline();
|
|
74
|
+
const snapshots = await collectProviderRuntimeSnapshots(this.registry.list(), hostServices);
|
|
75
|
+
|
|
76
|
+
const planInput = buildSharedPlannerInput(request, snapshots, online, interactionMode);
|
|
77
|
+
const outcome = await this.planner.planRoute(planInput);
|
|
78
|
+
|
|
79
|
+
if (!outcome.routePlan) {
|
|
80
|
+
// There is no second planner to degrade to: routing is the shared Rust
|
|
81
|
+
// core's semantics or nothing. Failing here keeps provider selection
|
|
82
|
+
// identical everywhere rather than forking it on a module load failure.
|
|
83
|
+
const reason = outcome.unavailableReason ?? "import_failed";
|
|
84
|
+
throw createInternal(`Route planner unavailable (${reason}).`, {
|
|
85
|
+
details: { plannerUnavailableReason: reason }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.selectFromSharedPlan(snapshots, outcome.routePlan);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private selectFromSharedPlan(
|
|
93
|
+
snapshots: ProviderRuntimeSnapshot[],
|
|
94
|
+
routePlan: SharedPlannerRoutePlan
|
|
95
|
+
): RouteSelection {
|
|
96
|
+
if (!routePlan.selectedProviderId) {
|
|
97
|
+
throw this.routePlanFailure(routePlan);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return this.buildSelectionFromRoutePlan(snapshots, routePlan);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private buildSelectionFromRoutePlan(
|
|
104
|
+
snapshots: ProviderRuntimeSnapshot[],
|
|
105
|
+
routePlan: SharedPlannerRoutePlan
|
|
106
|
+
): RouteSelection {
|
|
107
|
+
const orderedProviders = routePlan.candidates
|
|
108
|
+
.map((candidate) =>
|
|
109
|
+
snapshots.find((snapshot) => snapshot.descriptor.id === candidate.providerId)
|
|
110
|
+
)
|
|
111
|
+
.filter((snapshot): snapshot is ProviderRuntimeSnapshot => snapshot !== undefined);
|
|
112
|
+
|
|
113
|
+
const selected = orderedProviders[0];
|
|
114
|
+
if (!selected) {
|
|
115
|
+
throw createInternal("Route plan selected a provider that is no longer registered.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
provider: selected.provider,
|
|
120
|
+
fallbackProviders: orderedProviders.slice(1).map((snapshot) => snapshot.provider),
|
|
121
|
+
routePlan,
|
|
122
|
+
explanation: routePlan.explanation.summary
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Routing failure throws before any `route_decided` telemetry is emitted, so the
|
|
128
|
+
* plan's diagnostics are attached to the exception — that is the only channel
|
|
129
|
+
* through which a caller learns *why* each provider was rejected.
|
|
130
|
+
*/
|
|
131
|
+
private routePlanFailure(routePlan: SharedPlannerRoutePlan): never {
|
|
132
|
+
const message = routePlan.explanation.summary;
|
|
133
|
+
const details = {
|
|
134
|
+
details: {
|
|
135
|
+
failureCode: routePlan.failureCode,
|
|
136
|
+
rejectedProviders: routePlan.rejectedProviders
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
switch (routePlan.failureCode) {
|
|
140
|
+
case "offline":
|
|
141
|
+
throw createOffline(message, details);
|
|
142
|
+
case "unavailable":
|
|
143
|
+
throw createUnavailable(message, details);
|
|
144
|
+
case "capability_mismatch":
|
|
145
|
+
default:
|
|
146
|
+
throw createCapabilityMismatch(message, details);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/core/sse.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single dispatched server-sent event.
|
|
3
|
+
*
|
|
4
|
+
* `event` and `id` are `undefined` when the stream did not carry those fields.
|
|
5
|
+
* `data` is the accumulated data payload with its trailing newline removed, and
|
|
6
|
+
* is passed through verbatim — sentinels such as OpenAI's `[DONE]` are the
|
|
7
|
+
* caller's concern, not the framing layer's.
|
|
8
|
+
*/
|
|
9
|
+
export interface SseEvent {
|
|
10
|
+
event?: string;
|
|
11
|
+
data: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Frames a raw byte stream into server-sent events.
|
|
17
|
+
*
|
|
18
|
+
* Follows the WHATWG server-sent events stream parsing rules, minus
|
|
19
|
+
* reconnection: `retry` is parsed as a field and ignored, and there is no
|
|
20
|
+
* Last-Event-ID reconnect logic. Concretely that means:
|
|
21
|
+
*
|
|
22
|
+
* - lines end with LF, CRLF, or bare CR;
|
|
23
|
+
* - a line beginning with `:` is a comment (servers send these as keep-alives);
|
|
24
|
+
* - `field: value` strips exactly one leading space from the value, and a line
|
|
25
|
+
* with no colon is that field with an empty value;
|
|
26
|
+
* - `data` fields accumulate, joined by newlines;
|
|
27
|
+
* - a blank line dispatches the accumulated event, or resets without
|
|
28
|
+
* dispatching if no data was accumulated;
|
|
29
|
+
* - a stream that ends mid-event does **not** dispatch the partial event, since
|
|
30
|
+
* a truncated event must never be reported as a complete one.
|
|
31
|
+
*
|
|
32
|
+
* Chunk boundaries are meaningless: this decodes incrementally (`stream: true`)
|
|
33
|
+
* so a boundary may fall inside a multi-byte UTF-8 sequence or mid-line.
|
|
34
|
+
*
|
|
35
|
+
* Held to `contracts/fixtures/streaming/sse-framing.json` together with the
|
|
36
|
+
* Swift and Kotlin implementations of the same protocol.
|
|
37
|
+
*
|
|
38
|
+
* @param chunks - Raw response body chunks, in arrival order.
|
|
39
|
+
*/
|
|
40
|
+
export async function* parseSseStream(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<SseEvent> {
|
|
41
|
+
const decoder = new TextDecoder("utf-8");
|
|
42
|
+
let buffer = "";
|
|
43
|
+
|
|
44
|
+
let eventType: string | undefined;
|
|
45
|
+
let data: string | undefined;
|
|
46
|
+
let id: string | undefined;
|
|
47
|
+
|
|
48
|
+
const takeEvent = (): SseEvent | undefined => {
|
|
49
|
+
const dispatched = data === undefined ? undefined : buildEvent(eventType, data, id);
|
|
50
|
+
eventType = undefined;
|
|
51
|
+
data = undefined;
|
|
52
|
+
id = undefined;
|
|
53
|
+
return dispatched;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleLine = (line: string): SseEvent | undefined => {
|
|
57
|
+
if (line.length === 0) return takeEvent();
|
|
58
|
+
if (line.startsWith(":")) return undefined;
|
|
59
|
+
|
|
60
|
+
const colon = line.indexOf(":");
|
|
61
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
62
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
63
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
64
|
+
|
|
65
|
+
switch (field) {
|
|
66
|
+
case "event":
|
|
67
|
+
eventType = value;
|
|
68
|
+
break;
|
|
69
|
+
case "data":
|
|
70
|
+
data = data === undefined ? value : `${data}\n${value}`;
|
|
71
|
+
break;
|
|
72
|
+
case "id":
|
|
73
|
+
// Per the SSE spec an id containing a NUL is ignored outright.
|
|
74
|
+
if (!value.includes("\0")) id = value;
|
|
75
|
+
break;
|
|
76
|
+
default:
|
|
77
|
+
// `retry` and anything unrecognized are dropped rather than erroring.
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for await (const chunk of chunks) {
|
|
85
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
86
|
+
|
|
87
|
+
for (;;) {
|
|
88
|
+
const line = takeLine();
|
|
89
|
+
if (line === undefined) break;
|
|
90
|
+
const dispatched = handleLine(line);
|
|
91
|
+
if (dispatched) yield dispatched;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A trailing bare CR is ambiguous until the stream ends: it could still have
|
|
96
|
+
// been the first half of a CRLF.
|
|
97
|
+
buffer += decoder.decode();
|
|
98
|
+
for (;;) {
|
|
99
|
+
const line = takeLine(true);
|
|
100
|
+
if (line === undefined) break;
|
|
101
|
+
const dispatched = handleLine(line);
|
|
102
|
+
if (dispatched) yield dispatched;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function takeLine(atEnd = false): string | undefined {
|
|
106
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
107
|
+
const char = buffer[index];
|
|
108
|
+
if (char === "\n") {
|
|
109
|
+
const line = buffer.slice(0, index);
|
|
110
|
+
buffer = buffer.slice(index + 1);
|
|
111
|
+
return line;
|
|
112
|
+
}
|
|
113
|
+
if (char === "\r") {
|
|
114
|
+
const isLast = index === buffer.length - 1;
|
|
115
|
+
// Withhold a trailing CR until we know whether an LF follows, unless
|
|
116
|
+
// the stream is over and no LF can arrive.
|
|
117
|
+
if (isLast && !atEnd) return undefined;
|
|
118
|
+
const skip = buffer[index + 1] === "\n" ? 2 : 1;
|
|
119
|
+
const line = buffer.slice(0, index);
|
|
120
|
+
buffer = buffer.slice(index + skip);
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildEvent(eventType: string | undefined, data: string, id: string | undefined): SseEvent {
|
|
129
|
+
return {
|
|
130
|
+
...(eventType !== undefined ? { event: eventType } : {}),
|
|
131
|
+
data,
|
|
132
|
+
...(id !== undefined ? { id } : {})
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TelemetryEvent, TelemetryService } from "@independo/inderun-contracts";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
TelemetryEvent,
|
|
5
|
+
TelemetryEventType,
|
|
6
|
+
TelemetryService
|
|
7
|
+
} from "@independo/inderun-contracts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default no-op telemetry handler that discards all events.
|
|
11
|
+
*/
|
|
12
|
+
export class NoOpTelemetryService implements TelemetryService {
|
|
13
|
+
/**
|
|
14
|
+
* Discards the telemetry event.
|
|
15
|
+
* @param _event - The telemetry event to discard.
|
|
16
|
+
*/
|
|
17
|
+
emit(_event: TelemetryEvent): void {
|
|
18
|
+
// No-op
|
|
19
|
+
}
|
|
20
|
+
}
|