@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 +15 -0
- package/README.md +88 -0
- package/package.json +26 -0
- package/src/async-queue.js +67 -0
- package/src/backends/llamacpp.js +234 -0
- package/src/backends/max.js +196 -0
- package/src/backends/mojo.js +33 -0
- package/src/backends/ollama.js +249 -0
- package/src/backends/sglang.js +192 -0
- package/src/backends/stub.js +96 -0
- package/src/backends/vllm.js +244 -0
- package/src/engine-process.js +167 -0
- package/src/index.js +87 -0
- package/src/protocol.js +88 -0
- package/src/resolve-binary.js +21 -0
|
@@ -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
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|