@infernetprotocol/engine 0.1.22

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025 Infernet Protocol
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @infernetprotocol/engine
2
+
3
+ Pluggable inference engine for the Infernet daemon. One async-iterator API
4
+ across backends; the consumer (chat executor, future job runners) doesn't
5
+ care which is loaded.
6
+
7
+ ## Backends
8
+
9
+ | Backend | Where it runs | When it's selected |
10
+ | -------- | ----------------------------------- | ----------------------------------------------------------- |
11
+ | `ollama` | Local Ollama daemon (HTTP) | **Default** when Ollama is reachable on `OLLAMA_HOST` |
12
+ | `mojo` | External Mojo+MAX binary | `INFERNET_ENGINE_BIN` set, or `backend:"mojo"` (experimental) |
13
+ | `stub` | In-process canned tokens | Fallback when neither is available — daemon still works |
14
+
15
+ **Ollama is the recommended default.** It already solves model download
16
+ + cache, multi-vendor GPU (CUDA / ROCm / Metal), per-platform installers,
17
+ and a stable streaming chat API. Operators install it once
18
+ (<https://ollama.com>), `ollama pull qwen2.5:7b`, and they're serving.
19
+
20
+ The `mojo` backend is kept for the eventual case where a custom Mojo+MAX
21
+ binary is worth squeezing extra perf out of — see
22
+ [`engine/mojo/`](../../engine/mojo) for the scaffold.
23
+
24
+ ## Usage
25
+
26
+ ```js
27
+ import { createEngine } from "@infernetprotocol/engine";
28
+
29
+ const engine = await createEngine(); // auto-selects backend
30
+ const { id, stream, cancel } = engine.generate({
31
+ messages: [{ role: "user", content: "hi" }],
32
+ model: "qwen2.5:7b"
33
+ });
34
+
35
+ for await (const ev of stream) {
36
+ if (ev.type === "token") process.stdout.write(ev.text);
37
+ if (ev.type === "done") console.log("\nfinished:", ev.reason);
38
+ if (ev.type === "error") throw new Error(ev.message);
39
+ }
40
+
41
+ await engine.shutdown();
42
+ ```
43
+
44
+ ## Auto-selection precedence
45
+
46
+ 1. `INFERNET_ENGINE_BACKEND` env var (explicit override: `ollama`, `mojo`, `stub`)
47
+ 2. `INFERNET_ENGINE_BIN` set → `mojo`
48
+ 3. Ollama reachable on `OLLAMA_HOST` (default `http://localhost:11434`) → `ollama`
49
+ 4. → `stub`
50
+
51
+ ## Operator setup (typical)
52
+
53
+ ```bash
54
+ # 1. Install Ollama (one line per platform — see ollama.com).
55
+ curl -fsSL https://ollama.com/install.sh | sh
56
+
57
+ # 2. Pull whichever model your node will serve.
58
+ ollama pull qwen2.5:7b # 4.4 GB — fits a 6+ GB GPU
59
+ ollama pull qwen2.5:0.5b # 400 MB — useful for CPU smoke tests
60
+
61
+ # 3. Tell the daemon which model this node serves.
62
+ INFERNET_ENGINE_MODEL=qwen2.5:7b infernet start
63
+ ```
64
+
65
+ Pick whichever model fits your VRAM. Qwen 2.5 family is a reasonable
66
+ default; the engine is model-agnostic.
67
+
68
+ ## Wire protocol — `mojo` backend
69
+
70
+ The Mojo backend communicates with its binary over NDJSON v1 stdio.
71
+ Source of truth: [`src/protocol.js`](./src/protocol.js); mirrored on the
72
+ Mojo side in [`engine/mojo/src/main.mojo`](../../engine/mojo/src/main.mojo).
73
+ Bumping `PROTOCOL_VERSION` is a breaking change — both sides must move
74
+ together.
75
+
76
+ The Ollama backend talks HTTP to the operator's Ollama daemon, so the v1
77
+ protocol is purely an internal-IPC concern there.
78
+
79
+ ## Testing
80
+
81
+ `pnpm vitest run tests/engine.test.js` — 16 tests covering:
82
+ - Protocol codec round-trip + version mismatch handling
83
+ - NDJSON splitter across chunk boundaries
84
+ - `EngineProcess` against `test/fake-engine.js` (Node script speaking the
85
+ protocol — no Mojo toolchain required)
86
+ - Ollama backend against an in-process fake HTTP server (no real Ollama
87
+ required) — streaming, cancellation, HTTP errors, missing model
88
+ - `createEngine` auto-selection
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@infernetprotocol/engine",
3
+ "version": "0.1.22",
4
+ "description": "Inference engine abstraction for Infernet — pluggable backends (Mojo+MAX, in-process stub) over a stable async-iterator API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./protocol": "./src/protocol.js",
11
+ "./engine-process": "./src/engine-process.js"
12
+ },
13
+ "files": [
14
+ "src/**/*.js",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/profullstack/infernet-protocol.git",
23
+ "directory": "packages/engine"
24
+ },
25
+ "homepage": "https://github.com/profullstack/infernet-protocol#readme"
26
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Single-producer / single-consumer async queue used to surface engine
3
+ * events as an async-iterable to backend callers.
4
+ *
5
+ * Shared by all backends (stub, ollama, mojo via EngineProcess) so the
6
+ * iteration semantics are identical regardless of whether events arrive
7
+ * from a child process, an HTTP stream, or in-process timers.
8
+ *
9
+ * const q = new AsyncQueue();
10
+ * q.push(value); q.end(); q.fail(err);
11
+ * for await (const v of q) { ... }
12
+ */
13
+
14
+ export class AsyncQueue {
15
+ constructor() {
16
+ this.values = [];
17
+ this.waiters = [];
18
+ this.closed = false;
19
+ this.error = null;
20
+ }
21
+
22
+ push(v) {
23
+ if (this.closed) return;
24
+ const w = this.waiters.shift();
25
+ if (w) w({ value: v, done: false });
26
+ else this.values.push(v);
27
+ }
28
+
29
+ fail(err) {
30
+ if (this.closed) return;
31
+ this.error = err;
32
+ this.closed = true;
33
+ while (this.waiters.length) {
34
+ const w = this.waiters.shift();
35
+ w({ value: undefined, done: true, _err: err });
36
+ }
37
+ }
38
+
39
+ end() {
40
+ if (this.closed) return;
41
+ this.closed = true;
42
+ while (this.waiters.length) {
43
+ const w = this.waiters.shift();
44
+ w({ value: undefined, done: true });
45
+ }
46
+ }
47
+
48
+ next() {
49
+ if (this.values.length) {
50
+ return Promise.resolve({ value: this.values.shift(), done: false });
51
+ }
52
+ if (this.closed) {
53
+ if (this.error) return Promise.reject(this.error);
54
+ return Promise.resolve({ value: undefined, done: true });
55
+ }
56
+ return new Promise((resolve, reject) => {
57
+ this.waiters.push((res) => {
58
+ if (res._err) reject(res._err);
59
+ else resolve(res);
60
+ });
61
+ });
62
+ }
63
+
64
+ [Symbol.asyncIterator]() {
65
+ return this;
66
+ }
67
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * llama.cpp / llama-swap backend.
3
+ *
4
+ * llama.cpp's `llama-server` exposes OpenAI-compatible chat-completions
5
+ * (default port 8080). llama-swap is a thin proxy that routes per-model
6
+ * to underlying llama.cpp instances and hot-swaps weights without
7
+ * keeping every model resident — same wire protocol either way.
8
+ *
9
+ * Why this exists alongside Ollama and vLLM:
10
+ * - lighter than Ollama for Apple Silicon / ARM / pure-CPU operators
11
+ * - no Python dependency like vLLM (single static C++ binary)
12
+ * - supports gguf quantizations Ollama doesn't always ship
13
+ * - llama-swap's model-swap behavior is great for nodes serving
14
+ * many small models on commodity hardware
15
+ *
16
+ * The adapter speaks OpenAI's /v1/chat/completions SSE — same logic
17
+ * as vllm.js, different defaults.
18
+ */
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { AsyncQueue } from "../async-queue.js";
22
+ import { MSG, PROTOCOL_VERSION } from "../protocol.js";
23
+
24
+ const DEFAULT_HOST = "http://localhost:8080";
25
+ const PROBE_TIMEOUT_MS = 500;
26
+
27
+ export const LLAMACPP_DEFAULT_HOST = DEFAULT_HOST;
28
+
29
+ export async function isLlamacppReachable(host = DEFAULT_HOST, timeoutMs = PROBE_TIMEOUT_MS) {
30
+ try {
31
+ const ctrl = new AbortController();
32
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
33
+ // /health is llama.cpp; /v1/models works for both llama-server and
34
+ // llama-swap. Try /v1/models first (broadest compatibility).
35
+ const res = await fetch(new URL("/v1/models", host), { signal: ctrl.signal });
36
+ clearTimeout(t);
37
+ return res.ok;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export async function createLlamacppBackend({
44
+ host = process.env.LLAMACPP_HOST ?? DEFAULT_HOST,
45
+ apiKey = process.env.LLAMACPP_API_KEY ?? null,
46
+ defaultModel = process.env.LLAMACPP_MODEL ?? process.env.INFERNET_ENGINE_MODEL ?? null,
47
+ skipProbe = false,
48
+ fetchImpl = globalThis.fetch
49
+ } = {}) {
50
+ if (!skipProbe) {
51
+ const ok = await isLlamacppReachable(host);
52
+ if (!ok) {
53
+ throw new Error(
54
+ `llama.cpp not reachable at ${host} — start with 'llama-server' or 'llama-swap', or set LLAMACPP_HOST`
55
+ );
56
+ }
57
+ }
58
+
59
+ return {
60
+ kind: "llamacpp",
61
+ host,
62
+ hasApiKey: Boolean(apiKey),
63
+ generate({ messages, id, model = null, max_tokens, temperature } = {}) {
64
+ const genId = id ?? randomUUID();
65
+ const stream = new AsyncQueue();
66
+ const ctrl = new AbortController();
67
+ const resolvedModel = model ?? defaultModel;
68
+
69
+ (async () => {
70
+ if (!resolvedModel) {
71
+ stream.push({
72
+ v: PROTOCOL_VERSION,
73
+ type: MSG.ERROR,
74
+ id: genId,
75
+ message:
76
+ "llamacpp backend: no model — set LLAMACPP_MODEL or pass model in the job"
77
+ });
78
+ stream.end();
79
+ return;
80
+ }
81
+
82
+ const body = {
83
+ model: resolvedModel,
84
+ messages: Array.isArray(messages) ? messages : [],
85
+ stream: true
86
+ };
87
+ if (typeof temperature === "number") body.temperature = temperature;
88
+ if (typeof max_tokens === "number") body.max_tokens = max_tokens;
89
+
90
+ const headers = { "content-type": "application/json" };
91
+ if (apiKey) headers["authorization"] = `Bearer ${apiKey}`;
92
+
93
+ let res;
94
+ try {
95
+ res = await fetchImpl(new URL("/v1/chat/completions", host), {
96
+ method: "POST",
97
+ headers,
98
+ body: JSON.stringify(body),
99
+ signal: ctrl.signal
100
+ });
101
+ } catch (err) {
102
+ pushTerminal(stream, genId, ctrl, err, "");
103
+ return;
104
+ }
105
+
106
+ if (!res.ok) {
107
+ let detail = "";
108
+ try { detail = await res.text(); } catch { /* best-effort */ }
109
+ stream.push({
110
+ v: PROTOCOL_VERSION,
111
+ type: MSG.ERROR,
112
+ id: genId,
113
+ message: `llamacpp HTTP ${res.status}: ${detail.slice(0, 200)}`
114
+ });
115
+ stream.end();
116
+ return;
117
+ }
118
+
119
+ let metaSent = false;
120
+ let acc = "";
121
+ let buf = "";
122
+ let finishReason = null;
123
+ const decoder = new TextDecoder();
124
+
125
+ try {
126
+ for await (const chunk of res.body) {
127
+ buf += decoder.decode(chunk, { stream: true });
128
+ let nl;
129
+ while ((nl = buf.indexOf("\n")) !== -1) {
130
+ const rawLine = buf.slice(0, nl);
131
+ buf = buf.slice(nl + 1);
132
+ const line = rawLine.trim();
133
+ if (!line || !line.startsWith("data:")) continue;
134
+ const payload = line.slice("data:".length).trim();
135
+ if (payload === "[DONE]") {
136
+ stream.push({
137
+ v: PROTOCOL_VERSION,
138
+ type: MSG.DONE,
139
+ id: genId,
140
+ reason: finishReason ?? "stop",
141
+ text: acc,
142
+ finished_at: new Date().toISOString()
143
+ });
144
+ stream.end();
145
+ return;
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(payload);
150
+ } catch {
151
+ continue;
152
+ }
153
+
154
+ if (!metaSent) {
155
+ stream.push({
156
+ v: PROTOCOL_VERSION,
157
+ type: MSG.META,
158
+ id: genId,
159
+ model: parsed.model ?? resolvedModel,
160
+ started_at: new Date().toISOString()
161
+ });
162
+ metaSent = true;
163
+ }
164
+
165
+ const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
166
+ const text = choice?.delta?.content ?? "";
167
+ if (text) {
168
+ acc += text;
169
+ stream.push({
170
+ v: PROTOCOL_VERSION,
171
+ type: MSG.TOKEN,
172
+ id: genId,
173
+ text
174
+ });
175
+ }
176
+ if (choice?.finish_reason) {
177
+ finishReason = choice.finish_reason;
178
+ }
179
+ }
180
+ }
181
+ stream.push({
182
+ v: PROTOCOL_VERSION,
183
+ type: MSG.DONE,
184
+ id: genId,
185
+ reason: finishReason ?? "stop",
186
+ text: acc,
187
+ finished_at: new Date().toISOString()
188
+ });
189
+ stream.end();
190
+ } catch (err) {
191
+ pushTerminal(stream, genId, ctrl, err, acc);
192
+ }
193
+ })().catch((err) => {
194
+ stream.push({
195
+ v: PROTOCOL_VERSION,
196
+ type: MSG.ERROR,
197
+ id: genId,
198
+ message: err?.message ?? String(err)
199
+ });
200
+ stream.end();
201
+ });
202
+
203
+ return {
204
+ id: genId,
205
+ stream,
206
+ cancel: () => ctrl.abort()
207
+ };
208
+ },
209
+ async shutdown() {
210
+ // The llama.cpp / llama-swap server is owned by the operator.
211
+ }
212
+ };
213
+ }
214
+
215
+ function pushTerminal(stream, genId, ctrl, err, accSoFar) {
216
+ if (ctrl.signal.aborted) {
217
+ stream.push({
218
+ v: PROTOCOL_VERSION,
219
+ type: MSG.DONE,
220
+ id: genId,
221
+ reason: "cancel",
222
+ text: accSoFar,
223
+ finished_at: new Date().toISOString()
224
+ });
225
+ } else {
226
+ stream.push({
227
+ v: PROTOCOL_VERSION,
228
+ type: MSG.ERROR,
229
+ id: genId,
230
+ message: err?.message ?? String(err)
231
+ });
232
+ }
233
+ stream.end();
234
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Modular MAX backend — talks to a local MAX serve instance over the
3
+ * OpenAI-compatible chat completions endpoint.
4
+ *
5
+ * MAX (Modular Accelerated Xecution) is Modular's high-performance inference
6
+ * engine. It benchmarks slightly ahead of vLLM on throughput (PagedAttention
7
+ * equivalent, speculative decoding on by default). NVIDIA + CPU targets.
8
+ *
9
+ * Start: max serve --model-path <huggingface-id> [--port 8000]
10
+ *
11
+ * Wire protocol is OpenAI SSE — identical to vLLM. Default port is 8000
12
+ * (same as vLLM), so MODULAR_MAX_HOST must be set explicitly if both run
13
+ * on the same box (unusual). Auto-selection probes MAX_HOST first in the
14
+ * precedence chain.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { AsyncQueue } from "../async-queue.js";
19
+ import { MSG, PROTOCOL_VERSION } from "../protocol.js";
20
+
21
+ const DEFAULT_HOST = "http://localhost:8000";
22
+ const PROBE_TIMEOUT_MS = 500;
23
+
24
+ export const MAX_DEFAULT_HOST = DEFAULT_HOST;
25
+
26
+ export async function isMaxReachable(host = DEFAULT_HOST, timeoutMs = PROBE_TIMEOUT_MS) {
27
+ try {
28
+ const ctrl = new AbortController();
29
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
30
+ // MAX exposes /v1/models just like vLLM. We distinguish at selection
31
+ // time via MODULAR_MAX_HOST — if the env var is set, we assume MAX.
32
+ const res = await fetch(new URL("/v1/models", host), { signal: ctrl.signal });
33
+ clearTimeout(t);
34
+ return res.ok;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export async function createMaxBackend({
41
+ host = process.env.MODULAR_MAX_HOST ?? DEFAULT_HOST,
42
+ apiKey = process.env.MAX_API_KEY ?? null,
43
+ defaultModel = process.env.MAX_MODEL ?? process.env.INFERNET_ENGINE_MODEL ?? null,
44
+ skipProbe = false,
45
+ fetchImpl = globalThis.fetch
46
+ } = {}) {
47
+ if (!skipProbe) {
48
+ const ok = await isMaxReachable(host);
49
+ if (!ok) {
50
+ throw new Error(
51
+ `MAX not reachable at ${host} — start with 'max serve --model-path <model>' or set MODULAR_MAX_HOST`
52
+ );
53
+ }
54
+ }
55
+
56
+ return {
57
+ kind: "max",
58
+ host,
59
+ hasApiKey: Boolean(apiKey),
60
+ generate({ messages, id, model = null, max_tokens, temperature } = {}) {
61
+ const genId = id ?? randomUUID();
62
+ const stream = new AsyncQueue();
63
+ const ctrl = new AbortController();
64
+ const resolvedModel = model ?? defaultModel;
65
+
66
+ (async () => {
67
+ if (!resolvedModel) {
68
+ stream.push({
69
+ v: PROTOCOL_VERSION,
70
+ type: MSG.ERROR,
71
+ id: genId,
72
+ message: "max backend: no model — set MAX_MODEL or pass model in the job"
73
+ });
74
+ stream.end();
75
+ return;
76
+ }
77
+
78
+ const body = {
79
+ model: resolvedModel,
80
+ messages: Array.isArray(messages) ? messages : [],
81
+ stream: true
82
+ };
83
+ if (typeof temperature === "number") body.temperature = temperature;
84
+ if (typeof max_tokens === "number") body.max_tokens = max_tokens;
85
+
86
+ const headers = { "content-type": "application/json" };
87
+ if (apiKey) headers["authorization"] = `Bearer ${apiKey}`;
88
+
89
+ let res;
90
+ try {
91
+ res = await fetchImpl(new URL("/v1/chat/completions", host), {
92
+ method: "POST",
93
+ headers,
94
+ body: JSON.stringify(body),
95
+ signal: ctrl.signal
96
+ });
97
+ } catch (err) {
98
+ pushTerminal(stream, genId, ctrl, err, "");
99
+ return;
100
+ }
101
+
102
+ if (!res.ok) {
103
+ let detail = "";
104
+ try { detail = await res.text(); } catch { /* best-effort */ }
105
+ stream.push({
106
+ v: PROTOCOL_VERSION,
107
+ type: MSG.ERROR,
108
+ id: genId,
109
+ message: `max HTTP ${res.status}: ${detail.slice(0, 200)}`
110
+ });
111
+ stream.end();
112
+ return;
113
+ }
114
+
115
+ let metaSent = false;
116
+ let acc = "";
117
+ let buf = "";
118
+ let finishReason = null;
119
+ const decoder = new TextDecoder();
120
+
121
+ try {
122
+ for await (const chunk of res.body) {
123
+ buf += decoder.decode(chunk, { stream: true });
124
+ let nl;
125
+ while ((nl = buf.indexOf("\n")) !== -1) {
126
+ const rawLine = buf.slice(0, nl);
127
+ buf = buf.slice(nl + 1);
128
+ const line = rawLine.trim();
129
+ if (!line || !line.startsWith("data:")) continue;
130
+ const payload = line.slice("data:".length).trim();
131
+ if (payload === "[DONE]") {
132
+ stream.push({
133
+ v: PROTOCOL_VERSION,
134
+ type: MSG.DONE,
135
+ id: genId,
136
+ reason: finishReason ?? "stop",
137
+ text: acc,
138
+ finished_at: new Date().toISOString()
139
+ });
140
+ stream.end();
141
+ return;
142
+ }
143
+ let parsed;
144
+ try { parsed = JSON.parse(payload); } catch { continue; }
145
+
146
+ if (!metaSent) {
147
+ stream.push({
148
+ v: PROTOCOL_VERSION,
149
+ type: MSG.META,
150
+ id: genId,
151
+ model: parsed.model ?? resolvedModel,
152
+ started_at: new Date().toISOString()
153
+ });
154
+ metaSent = true;
155
+ }
156
+
157
+ const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
158
+ const text = choice?.delta?.content ?? "";
159
+ if (text) {
160
+ acc += text;
161
+ stream.push({ v: PROTOCOL_VERSION, type: MSG.TOKEN, id: genId, text });
162
+ }
163
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
164
+ }
165
+ }
166
+ stream.push({
167
+ v: PROTOCOL_VERSION,
168
+ type: MSG.DONE,
169
+ id: genId,
170
+ reason: finishReason ?? "stop",
171
+ text: acc,
172
+ finished_at: new Date().toISOString()
173
+ });
174
+ stream.end();
175
+ } catch (err) {
176
+ pushTerminal(stream, genId, ctrl, err, acc);
177
+ }
178
+ })().catch((err) => {
179
+ stream.push({ v: PROTOCOL_VERSION, type: MSG.ERROR, id: genId, message: err?.message ?? String(err) });
180
+ stream.end();
181
+ });
182
+
183
+ return { id: genId, stream, cancel: () => ctrl.abort() };
184
+ },
185
+ async shutdown() {}
186
+ };
187
+ }
188
+
189
+ function pushTerminal(stream, genId, ctrl, err, accSoFar) {
190
+ if (ctrl.signal.aborted) {
191
+ stream.push({ v: PROTOCOL_VERSION, type: MSG.DONE, id: genId, reason: "cancel", text: accSoFar, finished_at: new Date().toISOString() });
192
+ } else {
193
+ stream.push({ v: PROTOCOL_VERSION, type: MSG.ERROR, id: genId, message: err?.message ?? String(err) });
194
+ }
195
+ stream.end();
196
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Mojo backend — drives an external Mojo+MAX engine binary over the v1
3
+ * NDJSON protocol. The binary is built from `engine/mojo/` (sibling to
4
+ * `supabase/`); see that directory's README for build instructions.
5
+ */
6
+
7
+ import { EngineProcess } from "../engine-process.js";
8
+ import { resolveBinary } from "../resolve-binary.js";
9
+
10
+ export async function createMojoBackend({
11
+ binary,
12
+ model = null,
13
+ args = [],
14
+ env = {}
15
+ } = {}) {
16
+ const bin =
17
+ binary ?? resolveBinary({ envVar: "INFERNET_ENGINE_BIN", name: "infernet-engine" });
18
+ if (!bin) {
19
+ throw new Error(
20
+ "no Mojo engine binary found — set INFERNET_ENGINE_BIN or place `infernet-engine` on PATH"
21
+ );
22
+ }
23
+
24
+ const proc = new EngineProcess({ binary: bin, args, env, model });
25
+ await proc.start();
26
+
27
+ return {
28
+ kind: "mojo",
29
+ binary: bin,
30
+ generate: (req) => proc.generate(req),
31
+ shutdown: (opts) => proc.shutdown(opts)
32
+ };
33
+ }