@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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * vLLM backend — talks to a local vLLM server over HTTP using the
3
+ * OpenAI-compatible chat completions endpoint.
4
+ *
5
+ * Why this exists alongside the Ollama backend: vLLM is the high-throughput
6
+ * serving engine of choice for serious GPU operators (PagedAttention,
7
+ * tensor + pipeline parallelism via Ray, request batching). Ollama wins on
8
+ * "just works on any GPU + CPU + Apple Silicon"; vLLM wins on throughput
9
+ * and on serving big models that don't fit on one GPU. Operators pick
10
+ * whichever fits their hardware — we speak both.
11
+ *
12
+ * Discovery: `isVllmReachable()` does a fast probe of `/v1/models`. The
13
+ * factory in `index.js` calls it during auto-selection so a node with
14
+ * vLLM running picks `vllm` automatically; nodes without it fall through
15
+ * to `ollama` (or `stub`).
16
+ *
17
+ * Streaming: vLLM uses OpenAI-style Server-Sent Events — each line is
18
+ * `data: {...}` JSON, terminated by `data: [DONE]`. We translate that
19
+ * into our v1 protocol events.
20
+ *
21
+ * Cancellation: each generation owns an AbortController; `cancel()`
22
+ * aborts the in-flight fetch, which surfaces a `done` event with
23
+ * `reason: "cancel"`.
24
+ */
25
+
26
+ import { randomUUID } from "node:crypto";
27
+ import { AsyncQueue } from "../async-queue.js";
28
+ import { MSG, PROTOCOL_VERSION } from "../protocol.js";
29
+
30
+ const DEFAULT_HOST = "http://localhost:8000";
31
+ const PROBE_TIMEOUT_MS = 500;
32
+
33
+ export const VLLM_DEFAULT_HOST = DEFAULT_HOST;
34
+
35
+ export async function isVllmReachable(host = DEFAULT_HOST, timeoutMs = PROBE_TIMEOUT_MS) {
36
+ try {
37
+ const ctrl = new AbortController();
38
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
39
+ const res = await fetch(new URL("/v1/models", host), { signal: ctrl.signal });
40
+ clearTimeout(t);
41
+ return res.ok;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export async function createVllmBackend({
48
+ host = process.env.VLLM_HOST ?? DEFAULT_HOST,
49
+ apiKey = process.env.VLLM_API_KEY ?? null,
50
+ defaultModel = process.env.VLLM_MODEL ?? process.env.INFERNET_ENGINE_MODEL ?? null,
51
+ skipProbe = false,
52
+ fetchImpl = globalThis.fetch
53
+ } = {}) {
54
+ if (!skipProbe) {
55
+ const ok = await isVllmReachable(host);
56
+ if (!ok) {
57
+ throw new Error(
58
+ `vLLM not reachable at ${host} — start with 'vllm serve <model>' or set VLLM_HOST`
59
+ );
60
+ }
61
+ }
62
+
63
+ return {
64
+ kind: "vllm",
65
+ host,
66
+ hasApiKey: Boolean(apiKey),
67
+ generate({ messages, id, model = null, max_tokens, temperature } = {}) {
68
+ const genId = id ?? randomUUID();
69
+ const stream = new AsyncQueue();
70
+ const ctrl = new AbortController();
71
+ const resolvedModel = model ?? defaultModel;
72
+
73
+ (async () => {
74
+ if (!resolvedModel) {
75
+ stream.push({
76
+ v: PROTOCOL_VERSION,
77
+ type: MSG.ERROR,
78
+ id: genId,
79
+ message:
80
+ "vllm backend: no model — set VLLM_MODEL or pass model in the job"
81
+ });
82
+ stream.end();
83
+ return;
84
+ }
85
+
86
+ const body = {
87
+ model: resolvedModel,
88
+ messages: Array.isArray(messages) ? messages : [],
89
+ stream: true
90
+ };
91
+ if (typeof temperature === "number") body.temperature = temperature;
92
+ if (typeof max_tokens === "number") body.max_tokens = max_tokens;
93
+
94
+ const headers = { "content-type": "application/json" };
95
+ if (apiKey) headers["authorization"] = `Bearer ${apiKey}`;
96
+
97
+ let res;
98
+ try {
99
+ res = await fetchImpl(new URL("/v1/chat/completions", host), {
100
+ method: "POST",
101
+ headers,
102
+ body: JSON.stringify(body),
103
+ signal: ctrl.signal
104
+ });
105
+ } catch (err) {
106
+ pushTerminal(stream, genId, ctrl, err, "");
107
+ return;
108
+ }
109
+
110
+ if (!res.ok) {
111
+ let detail = "";
112
+ try {
113
+ detail = await res.text();
114
+ } catch {
115
+ // best-effort
116
+ }
117
+ stream.push({
118
+ v: PROTOCOL_VERSION,
119
+ type: MSG.ERROR,
120
+ id: genId,
121
+ message: `vllm HTTP ${res.status}: ${detail.slice(0, 200)}`
122
+ });
123
+ stream.end();
124
+ return;
125
+ }
126
+
127
+ let metaSent = false;
128
+ let acc = "";
129
+ let buf = "";
130
+ let finishReason = null;
131
+ const decoder = new TextDecoder();
132
+
133
+ try {
134
+ for await (const chunk of res.body) {
135
+ buf += decoder.decode(chunk, { stream: true });
136
+ let nl;
137
+ while ((nl = buf.indexOf("\n")) !== -1) {
138
+ const rawLine = buf.slice(0, nl);
139
+ buf = buf.slice(nl + 1);
140
+ const line = rawLine.trim();
141
+ if (!line || !line.startsWith("data:")) continue;
142
+ const payload = line.slice("data:".length).trim();
143
+ if (payload === "[DONE]") {
144
+ stream.push({
145
+ v: PROTOCOL_VERSION,
146
+ type: MSG.DONE,
147
+ id: genId,
148
+ reason: finishReason ?? "stop",
149
+ text: acc,
150
+ finished_at: new Date().toISOString()
151
+ });
152
+ stream.end();
153
+ return;
154
+ }
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(payload);
158
+ } catch {
159
+ continue;
160
+ }
161
+
162
+ if (!metaSent) {
163
+ stream.push({
164
+ v: PROTOCOL_VERSION,
165
+ type: MSG.META,
166
+ id: genId,
167
+ model: parsed.model ?? resolvedModel,
168
+ started_at: new Date().toISOString()
169
+ });
170
+ metaSent = true;
171
+ }
172
+
173
+ const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
174
+ const text = choice?.delta?.content ?? "";
175
+ if (text) {
176
+ acc += text;
177
+ stream.push({
178
+ v: PROTOCOL_VERSION,
179
+ type: MSG.TOKEN,
180
+ id: genId,
181
+ text
182
+ });
183
+ }
184
+ if (choice?.finish_reason) {
185
+ finishReason = choice.finish_reason;
186
+ }
187
+ }
188
+ }
189
+ // body ended without [DONE] — surface as stop with whatever we have.
190
+ stream.push({
191
+ v: PROTOCOL_VERSION,
192
+ type: MSG.DONE,
193
+ id: genId,
194
+ reason: finishReason ?? "stop",
195
+ text: acc,
196
+ finished_at: new Date().toISOString()
197
+ });
198
+ stream.end();
199
+ } catch (err) {
200
+ pushTerminal(stream, genId, ctrl, err, acc);
201
+ }
202
+ })().catch((err) => {
203
+ stream.push({
204
+ v: PROTOCOL_VERSION,
205
+ type: MSG.ERROR,
206
+ id: genId,
207
+ message: err?.message ?? String(err)
208
+ });
209
+ stream.end();
210
+ });
211
+
212
+ return {
213
+ id: genId,
214
+ stream,
215
+ cancel: () => ctrl.abort()
216
+ };
217
+ },
218
+ async shutdown() {
219
+ // The vLLM server is owned by the operator, not by us.
220
+ // Nothing to clean up.
221
+ }
222
+ };
223
+ }
224
+
225
+ function pushTerminal(stream, genId, ctrl, err, accSoFar) {
226
+ if (ctrl.signal.aborted) {
227
+ stream.push({
228
+ v: PROTOCOL_VERSION,
229
+ type: MSG.DONE,
230
+ id: genId,
231
+ reason: "cancel",
232
+ text: accSoFar,
233
+ finished_at: new Date().toISOString()
234
+ });
235
+ } else {
236
+ stream.push({
237
+ v: PROTOCOL_VERSION,
238
+ type: MSG.ERROR,
239
+ id: genId,
240
+ message: err?.message ?? String(err)
241
+ });
242
+ }
243
+ stream.end();
244
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Long-lived child-process wrapper around an engine binary.
3
+ *
4
+ * Spawns the binary once, keeps it warm so model load cost isn't paid per
5
+ * request, and demultiplexes streamed responses by generation id. Each call
6
+ * to `generate()` returns an async-iterable stream of protocol events that
7
+ * terminates on the first `done` or `error` for that id.
8
+ *
9
+ * The wrapper is binary-agnostic — it talks to anything that speaks the
10
+ * NDJSON protocol in `./protocol.js`. The Mojo backend uses it; tests use
11
+ * it with a fake Node-based engine.
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { randomUUID } from "node:crypto";
16
+ import { AsyncQueue } from "./async-queue.js";
17
+ import { encode, MSG, NdjsonSplitter } from "./protocol.js";
18
+
19
+ export class EngineProcess {
20
+ /**
21
+ * @param {{ binary: string, args?: string[], env?: Record<string,string>, model?: string|null, cwd?: string }} opts
22
+ */
23
+ constructor({ binary, args = [], env = {}, model = null, cwd } = {}) {
24
+ if (!binary) throw new Error("EngineProcess: binary is required");
25
+ this.binary = binary;
26
+ this.args = args;
27
+ this.env = env;
28
+ this.model = model;
29
+ this.cwd = cwd;
30
+ this.child = null;
31
+ this.streams = new Map();
32
+ this.splitter = new NdjsonSplitter();
33
+ this.closed = false;
34
+ this._readyResolve = null;
35
+ this._readyReject = null;
36
+ }
37
+
38
+ async start() {
39
+ this.child = spawn(this.binary, this.args, {
40
+ stdio: ["pipe", "pipe", "inherit"],
41
+ env: { ...process.env, ...this.env },
42
+ cwd: this.cwd
43
+ });
44
+ this.child.stdout.setEncoding("utf8");
45
+ this.child.stdout.on("data", (chunk) => this._onStdout(chunk));
46
+ this.child.on("exit", (code, signal) => this._onExit(code, signal));
47
+ this.child.on("error", (err) => this._onChildError(err));
48
+
49
+ const ready = new Promise((resolve, reject) => {
50
+ this._readyResolve = resolve;
51
+ this._readyReject = reject;
52
+ });
53
+
54
+ if (this.model) {
55
+ this._send({ type: MSG.LOAD, model: this.model });
56
+ }
57
+
58
+ return ready;
59
+ }
60
+
61
+ _send(msg) {
62
+ if (!this.child || this.closed) throw new Error("engine not running");
63
+ this.child.stdin.write(encode(msg));
64
+ }
65
+
66
+ _onStdout(chunk) {
67
+ for (const msg of this.splitter.push(chunk)) {
68
+ if (msg.type === MSG.READY) {
69
+ if (this._readyResolve) {
70
+ this._readyResolve(msg);
71
+ this._readyResolve = null;
72
+ this._readyReject = null;
73
+ }
74
+ continue;
75
+ }
76
+ if (msg.type === MSG.LOG) {
77
+ const level = msg.level ?? "info";
78
+ process.stderr.write(`[engine ${level}] ${msg.message ?? ""}\n`);
79
+ continue;
80
+ }
81
+ const id = msg.id;
82
+ if (id && this.streams.has(id)) {
83
+ const q = this.streams.get(id);
84
+ q.push(msg);
85
+ if (msg.type === MSG.DONE || msg.type === MSG.ERROR) {
86
+ q.end();
87
+ this.streams.delete(id);
88
+ }
89
+ continue;
90
+ }
91
+ // Engine-level error before ready.
92
+ if (msg.type === MSG.ERROR && this._readyReject) {
93
+ this._readyReject(new Error(msg.message ?? "engine error during start"));
94
+ this._readyResolve = null;
95
+ this._readyReject = null;
96
+ }
97
+ }
98
+ }
99
+
100
+ _onExit(code, signal) {
101
+ if (this.closed) return;
102
+ this.closed = true;
103
+ const err = new Error(`engine exited (code=${code ?? "null"}, signal=${signal ?? "null"})`);
104
+ for (const q of this.streams.values()) q.fail(err);
105
+ this.streams.clear();
106
+ if (this._readyReject) {
107
+ this._readyReject(err);
108
+ this._readyResolve = null;
109
+ this._readyReject = null;
110
+ }
111
+ }
112
+
113
+ _onChildError(err) {
114
+ if (this._readyReject) {
115
+ this._readyReject(err);
116
+ this._readyResolve = null;
117
+ this._readyReject = null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Start a generation. Returns `{ id, stream, cancel }` where `stream`
123
+ * is an async-iterable yielding protocol events (`meta`, `token`,
124
+ * `done`, `error`) until the generation terminates.
125
+ */
126
+ generate(req = {}) {
127
+ if (this.closed) throw new Error("engine closed");
128
+ const id = req.id ?? randomUUID();
129
+ const q = new AsyncQueue();
130
+ this.streams.set(id, q);
131
+ this._send({ type: MSG.GENERATE, ...req, id });
132
+ return {
133
+ id,
134
+ stream: q,
135
+ cancel: () => {
136
+ try {
137
+ this._send({ type: MSG.CANCEL, id });
138
+ } catch {
139
+ // engine already gone — caller will see done/error via stream
140
+ }
141
+ }
142
+ };
143
+ }
144
+
145
+ async shutdown({ timeoutMs = 5000 } = {}) {
146
+ if (this.closed || !this.child) return;
147
+ try {
148
+ this._send({ type: MSG.SHUTDOWN });
149
+ } catch {
150
+ // best-effort
151
+ }
152
+ await new Promise((resolve) => {
153
+ const t = setTimeout(() => {
154
+ try {
155
+ this.child.kill("SIGTERM");
156
+ } catch {
157
+ // already dead
158
+ }
159
+ resolve();
160
+ }, timeoutMs);
161
+ this.child.once("exit", () => {
162
+ clearTimeout(t);
163
+ resolve();
164
+ });
165
+ });
166
+ }
167
+ }
package/src/index.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @infernetprotocol/engine — pluggable inference engine.
3
+ *
4
+ * const engine = await createEngine(); // auto-selects
5
+ * const { stream } = engine.generate({ messages, model });
6
+ * for await (const ev of stream) {
7
+ * // ev.type ∈ "meta" | "token" | "done" | "error"
8
+ * }
9
+ * await engine.shutdown();
10
+ *
11
+ * Backends:
12
+ * - "max" — Modular MAX serve (highest throughput, OpenAI-compatible).
13
+ * MODULAR_MAX_HOST or default :8000.
14
+ * - "sglang" — SGLang (RadixAttention, speculative decoding). SGLANG_HOST
15
+ * or default :30000.
16
+ * - "vllm" — vLLM (PagedAttention, tensor parallelism via Ray). VLLM_HOST
17
+ * or default :8000.
18
+ * - "llamacpp" — llama.cpp / llama-swap. Lightweight, no Python. :8080.
19
+ * - "ollama" — Ollama daemon. Easiest setup; CUDA/ROCm/Metal/CPU.
20
+ * - "mojo" — Mojo + MAX binary. Experimental.
21
+ * - "stub" — in-process canned tokens. Fallback when nothing else runs.
22
+ *
23
+ * Auto-selection precedence (only if `opts.backend` is not set):
24
+ * 1. INFERNET_ENGINE_BACKEND env var — explicit override
25
+ * 2. INFERNET_ENGINE_BIN set — operator's Mojo binary
26
+ * 3. MODULAR_MAX_HOST set + reachable — MAX (best throughput)
27
+ * 4. SGLang reachable on SGLANG_HOST — :30000
28
+ * 5. vLLM reachable on VLLM_HOST — :8000
29
+ * 6. llama.cpp reachable — :8080
30
+ * 7. Ollama reachable — easy default
31
+ * 8. "stub"
32
+ */
33
+
34
+ import { createMojoBackend } from "./backends/mojo.js";
35
+ import { createOllamaBackend, isOllamaReachable } from "./backends/ollama.js";
36
+ import { createVllmBackend, isVllmReachable } from "./backends/vllm.js";
37
+ import { createSglangBackend, isSglangReachable } from "./backends/sglang.js";
38
+ import { createMaxBackend, isMaxReachable } from "./backends/max.js";
39
+ import { createLlamacppBackend, isLlamacppReachable } from "./backends/llamacpp.js";
40
+ import { createStubBackend } from "./backends/stub.js";
41
+
42
+ export * from "./protocol.js";
43
+ export { AsyncQueue } from "./async-queue.js";
44
+ export { EngineProcess } from "./engine-process.js";
45
+ export { resolveBinary } from "./resolve-binary.js";
46
+ export { isOllamaReachable } from "./backends/ollama.js";
47
+ export { isVllmReachable } from "./backends/vllm.js";
48
+ export { isSglangReachable } from "./backends/sglang.js";
49
+ export { isMaxReachable } from "./backends/max.js";
50
+ export { isLlamacppReachable } from "./backends/llamacpp.js";
51
+
52
+ export async function createEngine(opts = {}) {
53
+ const backend = opts.backend ?? (await autoSelectBackend());
54
+ switch (backend) {
55
+ case "max":
56
+ return createMaxBackend(opts);
57
+ case "sglang":
58
+ return createSglangBackend(opts);
59
+ case "vllm":
60
+ return createVllmBackend(opts);
61
+ case "llamacpp":
62
+ return createLlamacppBackend(opts);
63
+ case "ollama":
64
+ return createOllamaBackend(opts);
65
+ case "mojo":
66
+ return createMojoBackend(opts);
67
+ case "stub":
68
+ return createStubBackend(opts);
69
+ default:
70
+ throw new Error(`unknown engine backend: ${backend}`);
71
+ }
72
+ }
73
+
74
+ async function autoSelectBackend() {
75
+ const explicit = process.env.INFERNET_ENGINE_BACKEND;
76
+ if (explicit) return explicit;
77
+ if (process.env.INFERNET_ENGINE_BIN) return "mojo";
78
+ // Precedence: MAX > SGLang > vLLM (throughput order per benchmarks) >
79
+ // llama.cpp (lightweight, no Python) > Ollama (widest hardware support).
80
+ // MAX shares port 8000 with vLLM — distinguish via MODULAR_MAX_HOST.
81
+ if (process.env.MODULAR_MAX_HOST && await isMaxReachable(process.env.MODULAR_MAX_HOST)) return "max";
82
+ if (await isSglangReachable(process.env.SGLANG_HOST)) return "sglang";
83
+ if (await isVllmReachable(process.env.VLLM_HOST)) return "vllm";
84
+ if (await isLlamacppReachable(process.env.LLAMACPP_HOST)) return "llamacpp";
85
+ if (await isOllamaReachable(process.env.OLLAMA_HOST)) return "ollama";
86
+ return "stub";
87
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Engine IPC protocol — v1.
3
+ *
4
+ * Source of truth for messages exchanged between the JS sidecar wrapper
5
+ * (EngineProcess) and an engine binary on stdio. Format is NDJSON: one
6
+ * JSON object per line, each terminated by `\n`.
7
+ *
8
+ * Every message carries `v` (protocol version) and `type`. Generation-scoped
9
+ * messages also carry `id` so multiple in-flight generations can be
10
+ * demultiplexed on a single stdio pair.
11
+ *
12
+ * Outbound (JS → engine):
13
+ * { v:1, type:"load", model }
14
+ * { v:1, type:"generate", id, messages, model?, max_tokens?, temperature?, ... }
15
+ * { v:1, type:"cancel", id }
16
+ * { v:1, type:"shutdown" }
17
+ *
18
+ * Inbound (engine → JS):
19
+ * { v:1, type:"ready", model? } // engine-level
20
+ * { v:1, type:"meta", id, model?, started_at } // generation start
21
+ * { v:1, type:"token", id, text } // streamed token
22
+ * { v:1, type:"done", id, reason:"stop"|"length"|"cancel", text }
23
+ * { v:1, type:"error", id?, message } // id absent → engine-level
24
+ * { v:1, type:"log", level:"info"|"warn"|"error", message }
25
+ *
26
+ * Bumping PROTOCOL_VERSION is a breaking change. Mirror any change in
27
+ * engine/mojo/src/main.mojo.
28
+ */
29
+
30
+ export const PROTOCOL_VERSION = 1;
31
+
32
+ export const MSG = Object.freeze({
33
+ // outbound
34
+ LOAD: "load",
35
+ GENERATE: "generate",
36
+ CANCEL: "cancel",
37
+ SHUTDOWN: "shutdown",
38
+ // inbound
39
+ READY: "ready",
40
+ META: "meta",
41
+ TOKEN: "token",
42
+ DONE: "done",
43
+ ERROR: "error",
44
+ LOG: "log"
45
+ });
46
+
47
+ export function encode(msg) {
48
+ return JSON.stringify({ v: PROTOCOL_VERSION, ...msg }) + "\n";
49
+ }
50
+
51
+ export function decode(line) {
52
+ const trimmed = typeof line === "string" ? line.trim() : "";
53
+ if (!trimmed) return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(trimmed);
57
+ } catch (err) {
58
+ return { type: MSG.ERROR, message: `bad ndjson: ${err.message}`, raw: trimmed };
59
+ }
60
+ if (parsed.v !== PROTOCOL_VERSION) {
61
+ return {
62
+ type: MSG.ERROR,
63
+ message: `protocol version mismatch: expected ${PROTOCOL_VERSION}, got ${parsed.v}`
64
+ };
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ /**
70
+ * Buffered NDJSON splitter. Stdio chunks don't respect line boundaries, so
71
+ * we accumulate and yield one message per `\n` boundary.
72
+ */
73
+ export class NdjsonSplitter {
74
+ constructor() {
75
+ this.buf = "";
76
+ }
77
+
78
+ *push(chunk) {
79
+ this.buf += chunk;
80
+ let idx;
81
+ while ((idx = this.buf.indexOf("\n")) !== -1) {
82
+ const line = this.buf.slice(0, idx);
83
+ this.buf = this.buf.slice(idx + 1);
84
+ const msg = decode(line);
85
+ if (msg) yield msg;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolve the path to an engine binary.
3
+ *
4
+ * Lookup order:
5
+ * 1. Explicit env var (e.g. INFERNET_ENGINE_BIN) pointing at an existing file.
6
+ * 2. Bare command name — defer to PATH lookup at spawn time.
7
+ *
8
+ * A bundled-prebuild step (per-platform tarballs in `vendor/`) is intentional
9
+ * future work: the contract is just "return something spawn() can run".
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+
14
+ export function resolveBinary({ envVar, name } = {}) {
15
+ if (envVar) {
16
+ const fromEnv = process.env[envVar];
17
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
18
+ }
19
+ if (name) return name;
20
+ return null;
21
+ }