@independo/inderun-web 0.3.0-dev.12 → 0.3.0-dev.14

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.
@@ -0,0 +1,188 @@
1
+ import type { ModelPackage, TaskRequest, TaskResult } from "@independo/inderun-contracts";
2
+
3
+ /**
4
+ * Availability snapshot reported by an ONNX text generation runtime.
5
+ *
6
+ * The `reason` string uses the provider-internal capability vocabulary documented in
7
+ * `docs/architecture/onnx-runtime-provider-family.md`. It is not a public enum: the provider
8
+ * flattens every failure into a single `capability_unavailable` route rejection and carries the
9
+ * reason as human-readable text.
10
+ */
11
+ export interface OnnxRuntimeAvailability {
12
+ /** Whether the runtime can execute the given model package on this host right now. */
13
+ available: boolean;
14
+ /** Human-readable detail describing why the runtime is unavailable. */
15
+ reason?: string;
16
+ }
17
+
18
+ /**
19
+ * Normalized conversation turn handed to an ONNX text generation runtime.
20
+ */
21
+ export interface OnnxGenerationMessage {
22
+ /** The role of the author of this turn. */
23
+ role: "system" | "user" | "assistant";
24
+ /** Plain-text content of this turn. */
25
+ content: string;
26
+ }
27
+
28
+ /**
29
+ * Normalized generation request handed to an ONNX text generation runtime.
30
+ */
31
+ export interface OnnxGenerationInput {
32
+ /** The model package the provider was configured with. */
33
+ modelPackage: ModelPackage;
34
+ /** Conversation turns, normalized from `prompt` or `messages` by the provider. */
35
+ messages: OnnxGenerationMessage[];
36
+ /** Optional generation controls copied from the task request. */
37
+ generation?: NonNullable<TaskRequest["generation"]>;
38
+ }
39
+
40
+ /**
41
+ * Normalized generation result returned by an ONNX text generation runtime.
42
+ */
43
+ export interface OnnxGenerationOutput {
44
+ /** The generated text. */
45
+ text: string;
46
+ /** Optional normalized finish reason; the provider defaults to `stop`. */
47
+ finishReason?: TaskResult["finishReason"];
48
+ /** Optional token accounting, when the runtime reports it. */
49
+ usage?: TaskResult["usage"];
50
+ }
51
+
52
+ /**
53
+ * Failure category a runtime can signal so the provider maps it onto the IndeRun error taxonomy.
54
+ *
55
+ * - `capability`: model or runtime cannot serve this request (`CapabilityMismatch`).
56
+ * - `unavailable`: runtime initialization failure or resource exhaustion (`Unavailable`).
57
+ * - `timeout`: generation exceeded its budget or was aborted (`Timeout`).
58
+ * - `internal`: unexpected runtime failure (`Internal`).
59
+ */
60
+ export type OnnxRuntimeErrorKind = "capability" | "unavailable" | "timeout" | "internal";
61
+
62
+ /**
63
+ * Error type ONNX runtime implementations throw to steer IndeRun error normalization.
64
+ *
65
+ * Anything else thrown by a runtime is normalized to `Internal`.
66
+ */
67
+ export class OnnxRuntimeError extends Error {
68
+ /** Failure category used by the provider to select an IndeRun error class. */
69
+ readonly kind: OnnxRuntimeErrorKind;
70
+ /** Optional underlying error captured for telemetry details. */
71
+ readonly originalError?: unknown;
72
+
73
+ constructor(kind: OnnxRuntimeErrorKind, message: string, originalError?: unknown) {
74
+ super(message);
75
+ this.name = "OnnxRuntimeError";
76
+ this.kind = kind;
77
+ if (originalError !== undefined) this.originalError = originalError;
78
+
79
+ Object.setPrototypeOf(this, OnnxRuntimeError.prototype);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Injectable seam between the IndeRun ONNX Web provider and the actual on-device runtime.
85
+ *
86
+ * IndeRun ships a Transformers.js-backed implementation as the default and a deterministic
87
+ * fixture for tests, but applications can supply their own implementation (for example a
88
+ * hand-rolled `onnxruntime-web` pipeline) without touching the provider.
89
+ */
90
+ export interface OnnxTextGenerationRuntime {
91
+ /**
92
+ * Checks runtime package availability, execution backend availability, and model readiness.
93
+ * Implementations must resolve with an availability snapshot rather than throwing.
94
+ */
95
+ prepare(modelPackage: ModelPackage): Promise<OnnxRuntimeAvailability>;
96
+ /**
97
+ * Runs one Mode-1 generation. Implementations should honor `signal` where the underlying
98
+ * runtime allows it; ONNX inference is not hard-interruptible, so cancellation is best effort.
99
+ */
100
+ generate(input: OnnxGenerationInput, signal?: AbortSignal): Promise<OnnxGenerationOutput>;
101
+ }
102
+
103
+ /**
104
+ * Configuration for the deterministic fixture runtime.
105
+ */
106
+ export interface FixtureOnnxRuntimeOptions {
107
+ /** Availability snapshot returned by `prepare`. Defaults to `{ available: true }`. */
108
+ availability?: OnnxRuntimeAvailability;
109
+ /** Text or full output produced by `generate`. Defaults to a deterministic echo. */
110
+ respond?: (input: OnnxGenerationInput) => string | OnnxGenerationOutput;
111
+ /** Error thrown by `generate` instead of producing output. */
112
+ failWith?: unknown;
113
+ /** Artificial generation delay in milliseconds, used to exercise timeouts. */
114
+ delayMs?: number;
115
+ }
116
+
117
+ /**
118
+ * Creates a deterministic in-memory ONNX runtime.
119
+ *
120
+ * This is the test seam mandated by the ONNX Runtime provider family specification: it proves the
121
+ * model package contract, capability checks, routing, and error normalization without depending on
122
+ * a real ONNX runtime, and it lets demos exercise the on-device route offline.
123
+ *
124
+ * @param options - Scripted availability, response, failure, and delay behavior.
125
+ */
126
+ export function createFixtureOnnxRuntime(
127
+ options: FixtureOnnxRuntimeOptions = {}
128
+ ): OnnxTextGenerationRuntime {
129
+ const availability = options.availability ?? { available: true };
130
+
131
+ return {
132
+ async prepare(): Promise<OnnxRuntimeAvailability> {
133
+ return availability;
134
+ },
135
+ async generate(
136
+ input: OnnxGenerationInput,
137
+ signal?: AbortSignal
138
+ ): Promise<OnnxGenerationOutput> {
139
+ if (options.delayMs !== undefined && options.delayMs > 0) {
140
+ await delay(options.delayMs, signal);
141
+ }
142
+
143
+ throwIfAborted(signal);
144
+
145
+ if (options.failWith !== undefined) {
146
+ throw options.failWith;
147
+ }
148
+
149
+ const response = options.respond
150
+ ? options.respond(input)
151
+ : createFixtureText(input.modelPackage, input.messages);
152
+
153
+ return typeof response === "string" ? { text: response } : response;
154
+ }
155
+ };
156
+ }
157
+
158
+ function createFixtureText(modelPackage: ModelPackage, messages: OnnxGenerationMessage[]): string {
159
+ const lastUserMessage = [...messages].reverse().find((message) => message.role === "user");
160
+ const prompt = lastUserMessage?.content ?? "";
161
+ return `[fixture:${modelPackage.id}] ${prompt}`;
162
+ }
163
+
164
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
165
+ return new Promise<void>((resolve, reject) => {
166
+ const timer = setTimeout(() => {
167
+ signal?.removeEventListener("abort", onAbort);
168
+ resolve();
169
+ }, ms);
170
+
171
+ function onAbort(): void {
172
+ clearTimeout(timer);
173
+ reject(new OnnxRuntimeError("timeout", "Fixture ONNX generation was aborted."));
174
+ }
175
+
176
+ if (signal?.aborted) {
177
+ onAbort();
178
+ return;
179
+ }
180
+ signal?.addEventListener("abort", onAbort, { once: true });
181
+ });
182
+ }
183
+
184
+ function throwIfAborted(signal?: AbortSignal): void {
185
+ if (signal?.aborted) {
186
+ throw new OnnxRuntimeError("timeout", "Fixture ONNX generation was aborted.");
187
+ }
188
+ }
@@ -0,0 +1,345 @@
1
+ import type { ModelPackage } from "@independo/inderun-contracts";
2
+ import {
3
+ OnnxRuntimeError,
4
+ type OnnxGenerationInput,
5
+ type OnnxGenerationOutput,
6
+ type OnnxRuntimeAvailability,
7
+ type OnnxTextGenerationRuntime
8
+ } from "./runtime.js";
9
+
10
+ /**
11
+ * Structural view of the `@huggingface/transformers` entry points this runtime uses.
12
+ *
13
+ * The dependency is optional, so the module is typed structurally instead of imported for types.
14
+ */
15
+ export interface TransformersModule {
16
+ /** Builds a task pipeline, downloading or loading model assets as needed. */
17
+ pipeline: (
18
+ task: string,
19
+ model?: string,
20
+ options?: Record<string, unknown>
21
+ ) => Promise<TransformersTextGenerator>;
22
+ /** Global Transformers.js environment used to point the loader at local model assets. */
23
+ env?: Record<string, unknown>;
24
+ }
25
+
26
+ /**
27
+ * Structural view of a Transformers.js `text-generation` pipeline.
28
+ */
29
+ export type TransformersTextGenerator = (
30
+ input: unknown,
31
+ options?: Record<string, unknown>
32
+ ) => Promise<unknown>;
33
+
34
+ /**
35
+ * Configuration for the Transformers.js-backed ONNX runtime.
36
+ */
37
+ export interface TransformersJsRuntimeOptions {
38
+ /**
39
+ * Overrides how the `@huggingface/transformers` module is loaded. Tests inject a fake module;
40
+ * production uses a lazy dynamic import so the dependency stays optional.
41
+ */
42
+ load?: () => Promise<TransformersModule>;
43
+ /**
44
+ * Execution backend passed to Transformers.js. Defaults to `webgpu` when `navigator.gpu`
45
+ * exists and `wasm` otherwise. This is an ONNX Runtime execution backend, not an IndeRun
46
+ * routing concept.
47
+ */
48
+ device?: string;
49
+ /**
50
+ * Weight quantization passed to Transformers.js (for example `q4`, `q4f16`, `q8`, `fp16`,
51
+ * `fp32`).
52
+ *
53
+ * Defaults to a quantized variant (`q4f16` on WebGPU, `q4` otherwise) because Transformers.js
54
+ * would otherwise load full `fp32` weights, which exhausts browser memory for all but the
55
+ * smallest models. Set explicitly when the model does not publish the default variant.
56
+ */
57
+ dtype?: string;
58
+ /** Base path or URL for locally served model assets (`bundled` / `app_managed` sources). */
59
+ localModelPath?: string;
60
+ /** Extra options merged into the Transformers.js `pipeline()` call. */
61
+ pipelineOptions?: Record<string, unknown>;
62
+ /**
63
+ * Supplies a pre-built generator instead of letting this runtime construct one. Required for
64
+ * `programmatic` model sources, where the application owns model resolution.
65
+ */
66
+ createGenerator?: (context: {
67
+ modelPackage: ModelPackage;
68
+ module: TransformersModule;
69
+ device: string;
70
+ }) => Promise<TransformersTextGenerator>;
71
+ }
72
+
73
+ const TEXT_GENERATION_TASK = "text-generation";
74
+
75
+ /**
76
+ * Creates the default Web ONNX runtime, backed by Transformers.js (which runs ONNX models through
77
+ * `onnxruntime-web`).
78
+ *
79
+ * ONNX Runtime GenAI has no browser build, so Transformers.js fills the generative-loop role
80
+ * (tokenization, sampling, KV cache) behind the `OnnxTextGenerationRuntime` seam. Applications that
81
+ * need a different runtime implement the seam themselves and pass it to the provider.
82
+ *
83
+ * `@huggingface/transformers` is an optional dependency the application installs: when it is absent
84
+ * the runtime
85
+ * reports itself unavailable instead of throwing, so routing degrades to an explainable
86
+ * `capability_unavailable` rejection.
87
+ *
88
+ * @param options - Loader, backend, and model-resolution overrides.
89
+ */
90
+ export function createTransformersJsRuntime(
91
+ options: TransformersJsRuntimeOptions = {}
92
+ ): OnnxTextGenerationRuntime {
93
+ let modulePromise: Promise<TransformersModule> | undefined;
94
+ let generatorPromise: Promise<TransformersTextGenerator> | undefined;
95
+
96
+ async function loadModule(): Promise<TransformersModule> {
97
+ modulePromise ??= (options.load ?? loadTransformersModule)();
98
+ return modulePromise;
99
+ }
100
+
101
+ async function loadGenerator(modelPackage: ModelPackage): Promise<TransformersTextGenerator> {
102
+ generatorPromise ??= (async () => {
103
+ const module = await loadModule();
104
+ const device = resolveDevice(options.device);
105
+
106
+ if (options.createGenerator) {
107
+ return options.createGenerator({ modelPackage, module, device });
108
+ }
109
+
110
+ const modelRef = getModelRef(modelPackage);
111
+ if (!modelRef) {
112
+ throw new OnnxRuntimeError(
113
+ "capability",
114
+ "model source unavailable: the model package does not declare source.ref."
115
+ );
116
+ }
117
+
118
+ applyEnvironment(module, modelPackage, options.localModelPath);
119
+
120
+ const pipelineOptions: Record<string, unknown> = {
121
+ device,
122
+ dtype: options.dtype ?? defaultDtype(device),
123
+ ...(options.pipelineOptions ?? {})
124
+ };
125
+
126
+ return module.pipeline(TEXT_GENERATION_TASK, modelRef, pipelineOptions);
127
+ })();
128
+
129
+ return generatorPromise;
130
+ }
131
+
132
+ return {
133
+ async prepare(modelPackage: ModelPackage): Promise<OnnxRuntimeAvailability> {
134
+ let module: TransformersModule;
135
+ try {
136
+ module = await loadModule();
137
+ } catch (err) {
138
+ modulePromise = undefined;
139
+ return {
140
+ available: false,
141
+ reason: `runtime package unavailable: install the optional dependency @huggingface/transformers (${getErrorMessage(err)}).`
142
+ };
143
+ }
144
+
145
+ if (typeof module.pipeline !== "function") {
146
+ return {
147
+ available: false,
148
+ reason:
149
+ "runtime initialization failed: @huggingface/transformers did not expose pipeline()."
150
+ };
151
+ }
152
+
153
+ if (modelPackage.format !== "onnx") {
154
+ return {
155
+ available: false,
156
+ reason: `unsupported model format: the Transformers.js runtime consumes ONNX weights, received '${modelPackage.format}'.`
157
+ };
158
+ }
159
+
160
+ const sourceType = modelPackage.source?.sourceType;
161
+ if (sourceType === "programmatic" && !options.createGenerator) {
162
+ return {
163
+ available: false,
164
+ reason:
165
+ "model source unavailable: programmatic model packages require a createGenerator option."
166
+ };
167
+ }
168
+
169
+ if (!options.createGenerator && !getModelRef(modelPackage)) {
170
+ return {
171
+ available: false,
172
+ reason: "model source unavailable: the model package does not declare source.ref."
173
+ };
174
+ }
175
+
176
+ return { available: true };
177
+ },
178
+
179
+ async generate(
180
+ input: OnnxGenerationInput,
181
+ signal?: AbortSignal
182
+ ): Promise<OnnxGenerationOutput> {
183
+ let generator: TransformersTextGenerator;
184
+ try {
185
+ generator = await loadGenerator(input.modelPackage);
186
+ } catch (err) {
187
+ generatorPromise = undefined;
188
+ if (err instanceof OnnxRuntimeError) throw err;
189
+ if (isResourceExhaustion(err)) {
190
+ throw new OnnxRuntimeError(
191
+ "unavailable",
192
+ `insufficient memory to load the model: ${getErrorMessage(err)}. Try a smaller model or a more aggressive dtype (for example 'q4').`,
193
+ err
194
+ );
195
+ }
196
+ throw new OnnxRuntimeError(
197
+ "capability",
198
+ `model files missing or unreadable: ${getErrorMessage(err)}`,
199
+ err
200
+ );
201
+ }
202
+
203
+ if (signal?.aborted) {
204
+ throw new OnnxRuntimeError("timeout", "ONNX generation was aborted before it started.");
205
+ }
206
+
207
+ let raw: unknown;
208
+ try {
209
+ raw = await generator(input.messages, createGenerationOptions(input, signal));
210
+ } catch (err) {
211
+ throw new OnnxRuntimeError(
212
+ "internal",
213
+ `ONNX generation failed: ${getErrorMessage(err)}`,
214
+ err
215
+ );
216
+ }
217
+
218
+ const text = extractGeneratedText(raw);
219
+ if (text === undefined) {
220
+ throw new OnnxRuntimeError(
221
+ "internal",
222
+ "model output malformed: the Transformers.js pipeline returned no text."
223
+ );
224
+ }
225
+
226
+ return { text };
227
+ }
228
+ };
229
+ }
230
+
231
+ async function loadTransformersModule(): Promise<TransformersModule> {
232
+ // The specifier stays a literal so bundlers and dev servers resolve the bare package name; the
233
+ // `as string` cast keeps TypeScript from requiring the optional dependency's types here. Apps that
234
+ // do not install Transformers.js should pass their own `load` (or a different runtime) rather than
235
+ // relying on this default, which their bundler will fail to resolve.
236
+ return (await import("@huggingface/transformers" as string)) as TransformersModule;
237
+ }
238
+
239
+ /**
240
+ * Browser-viable weight variant. Transformers.js defaults to `fp32`, which makes ONNX Runtime
241
+ * allocate the full-precision model and fail session creation with an allocation error for
242
+ * anything but tiny models.
243
+ */
244
+ function defaultDtype(device: string): string {
245
+ return device === "webgpu" ? "q4f16" : "q4";
246
+ }
247
+
248
+ function resolveDevice(configured?: string): string {
249
+ if (configured !== undefined) return configured;
250
+ const gpu = (globalThis as { navigator?: { gpu?: unknown } }).navigator?.gpu;
251
+ return gpu ? "webgpu" : "wasm";
252
+ }
253
+
254
+ function getModelRef(modelPackage: ModelPackage): string | undefined {
255
+ const ref = modelPackage.source?.ref;
256
+ return typeof ref === "string" && ref.length > 0 ? ref : undefined;
257
+ }
258
+
259
+ function applyEnvironment(
260
+ module: TransformersModule,
261
+ modelPackage: ModelPackage,
262
+ localModelPath?: string
263
+ ): void {
264
+ const env = module.env;
265
+ if (!env) return;
266
+
267
+ const sourceType = modelPackage.source?.sourceType;
268
+ if (sourceType === "bundled" || sourceType === "app_managed") {
269
+ env.allowLocalModels = true;
270
+ env.allowRemoteModels = false;
271
+ if (localModelPath !== undefined) {
272
+ env.localModelPath = localModelPath;
273
+ }
274
+ }
275
+ }
276
+
277
+ function createGenerationOptions(
278
+ input: OnnxGenerationInput,
279
+ signal?: AbortSignal
280
+ ): Record<string, unknown> {
281
+ const generation = input.generation;
282
+ const generationOptions: Record<string, unknown> = {};
283
+
284
+ if (generation?.maxOutputTokens !== undefined) {
285
+ generationOptions.max_new_tokens = generation.maxOutputTokens;
286
+ }
287
+ if (generation?.temperature !== undefined) {
288
+ generationOptions.temperature = generation.temperature;
289
+ generationOptions.do_sample = generation.temperature > 0;
290
+ }
291
+ if (generation?.topP !== undefined) {
292
+ generationOptions.top_p = generation.topP;
293
+ }
294
+ if (generation?.stop !== undefined && generation.stop.length > 0) {
295
+ generationOptions.stop_strings = generation.stop;
296
+ }
297
+ if (signal !== undefined) {
298
+ generationOptions.signal = signal;
299
+ }
300
+
301
+ return generationOptions;
302
+ }
303
+
304
+ function extractGeneratedText(raw: unknown): string | undefined {
305
+ const first = Array.isArray(raw) ? raw[0] : raw;
306
+ if (!isRecord(first)) {
307
+ return typeof first === "string" ? first : undefined;
308
+ }
309
+
310
+ const generated = first.generated_text;
311
+ if (typeof generated === "string") {
312
+ return generated;
313
+ }
314
+
315
+ if (Array.isArray(generated)) {
316
+ const last = generated[generated.length - 1];
317
+ if (isRecord(last) && typeof last.content === "string") {
318
+ return last.content;
319
+ }
320
+ }
321
+
322
+ return undefined;
323
+ }
324
+
325
+ /**
326
+ * Detects out-of-memory failures surfaced by ONNX Runtime session creation, which arrive as
327
+ * allocation errors from the WASM/native layer rather than as typed errors.
328
+ */
329
+ function isResourceExhaustion(error: unknown): boolean {
330
+ const message = getErrorMessage(error).toLowerCase();
331
+ return (
332
+ message.includes("bad_alloc") ||
333
+ message.includes("out of memory") ||
334
+ message.includes("failed to allocate") ||
335
+ message.includes("allocation failed")
336
+ );
337
+ }
338
+
339
+ function isRecord(value: unknown): value is Record<string, unknown> {
340
+ return typeof value === "object" && value !== null;
341
+ }
342
+
343
+ function getErrorMessage(error: unknown): string {
344
+ return error instanceof Error ? error.message : String(error);
345
+ }