@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
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
|
+
}
|