@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,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama backend — talks to a local Ollama daemon over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Why this is the recommended default: Ollama already solves model
|
|
5
|
+
* download + cache, multi-vendor GPU (CUDA / ROCm / Metal), the OpenAI-ish
|
|
6
|
+
* chat API, and per-platform installers. We translate its NDJSON
|
|
7
|
+
* `/api/chat` stream into our v1 protocol events.
|
|
8
|
+
*
|
|
9
|
+
* Discovery: `isOllamaReachable()` does a fast probe of `/api/tags`. The
|
|
10
|
+
* factory in `index.js` calls it during auto-selection so a node with
|
|
11
|
+
* Ollama running picks `ollama` automatically; nodes without it fall
|
|
12
|
+
* through to `stub` (or `mojo` if `INFERNET_ENGINE_BIN` is set).
|
|
13
|
+
*
|
|
14
|
+
* Cancellation: each generation owns an AbortController; `cancel()`
|
|
15
|
+
* aborts the in-flight fetch, which surfaces a `done` event with
|
|
16
|
+
* `reason: "cancel"`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import os from "node:os";
|
|
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:11434";
|
|
25
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default thread cap for Ollama inference: half of the host's logical
|
|
29
|
+
* cores, floored at 1. Set as `options.num_thread` on every /api/chat
|
|
30
|
+
* call so a single inference job can't peg every core and starve the
|
|
31
|
+
* rest of the system (the daemon's poll loop, UFW, ssh, the operator's
|
|
32
|
+
* other workloads). Operators on dedicated boxes can lift the cap via
|
|
33
|
+
* the OLLAMA_NUM_THREAD env var; nobody should have to touch it
|
|
34
|
+
* otherwise.
|
|
35
|
+
*/
|
|
36
|
+
export function defaultNumThread() {
|
|
37
|
+
const logical = Number.isFinite(os.cpus()?.length) ? os.cpus().length : 0;
|
|
38
|
+
return Math.max(1, Math.floor(logical / 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const OLLAMA_DEFAULT_HOST = DEFAULT_HOST;
|
|
42
|
+
|
|
43
|
+
export async function isOllamaReachable(host = DEFAULT_HOST, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
44
|
+
try {
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
47
|
+
const res = await fetch(new URL("/api/tags", host), { signal: ctrl.signal });
|
|
48
|
+
clearTimeout(t);
|
|
49
|
+
return res.ok;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function createOllamaBackend({
|
|
56
|
+
host = process.env.OLLAMA_HOST ?? DEFAULT_HOST,
|
|
57
|
+
defaultModel = process.env.INFERNET_ENGINE_MODEL ?? null,
|
|
58
|
+
numThread = Number.parseInt(process.env.OLLAMA_NUM_THREAD ?? "", 10) || defaultNumThread(),
|
|
59
|
+
skipProbe = false,
|
|
60
|
+
fetchImpl = globalThis.fetch
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!skipProbe) {
|
|
63
|
+
const ok = await isOllamaReachable(host);
|
|
64
|
+
if (!ok) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Ollama not reachable at ${host} — install from https://ollama.com or set OLLAMA_HOST`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
kind: "ollama",
|
|
73
|
+
host,
|
|
74
|
+
numThread,
|
|
75
|
+
generate({ messages, id, model = null, max_tokens, temperature } = {}) {
|
|
76
|
+
const genId = id ?? randomUUID();
|
|
77
|
+
const stream = new AsyncQueue();
|
|
78
|
+
const ctrl = new AbortController();
|
|
79
|
+
const resolvedModel = model ?? defaultModel;
|
|
80
|
+
|
|
81
|
+
(async () => {
|
|
82
|
+
if (!resolvedModel) {
|
|
83
|
+
stream.push({
|
|
84
|
+
v: PROTOCOL_VERSION,
|
|
85
|
+
type: MSG.ERROR,
|
|
86
|
+
id: genId,
|
|
87
|
+
message:
|
|
88
|
+
"ollama backend: no model — set INFERNET_ENGINE_MODEL or pass model in the job"
|
|
89
|
+
});
|
|
90
|
+
stream.end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = {
|
|
95
|
+
model: resolvedModel,
|
|
96
|
+
messages: Array.isArray(messages) ? messages : [],
|
|
97
|
+
stream: true,
|
|
98
|
+
options: {}
|
|
99
|
+
};
|
|
100
|
+
if (typeof temperature === "number") body.options.temperature = temperature;
|
|
101
|
+
if (typeof max_tokens === "number") body.options.num_predict = max_tokens;
|
|
102
|
+
// Bound CPU usage by default so one inference can't peg
|
|
103
|
+
// every core. Operator can lift the cap via OLLAMA_NUM_THREAD.
|
|
104
|
+
if (Number.isFinite(numThread) && numThread > 0) {
|
|
105
|
+
body.options.num_thread = numThread;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let res;
|
|
109
|
+
try {
|
|
110
|
+
res = await fetchImpl(new URL("/api/chat", host), {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "content-type": "application/json" },
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
signal: ctrl.signal
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
pushTerminal(stream, genId, ctrl, err, "");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
let detail = "";
|
|
123
|
+
try {
|
|
124
|
+
detail = await res.text();
|
|
125
|
+
} catch {
|
|
126
|
+
// best-effort
|
|
127
|
+
}
|
|
128
|
+
stream.push({
|
|
129
|
+
v: PROTOCOL_VERSION,
|
|
130
|
+
type: MSG.ERROR,
|
|
131
|
+
id: genId,
|
|
132
|
+
message: `ollama HTTP ${res.status}: ${detail.slice(0, 200)}`
|
|
133
|
+
});
|
|
134
|
+
stream.end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let metaSent = false;
|
|
139
|
+
let acc = "";
|
|
140
|
+
let buf = "";
|
|
141
|
+
const decoder = new TextDecoder();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
for await (const chunk of res.body) {
|
|
145
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
146
|
+
let nl;
|
|
147
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
148
|
+
const line = buf.slice(0, nl).trim();
|
|
149
|
+
buf = buf.slice(nl + 1);
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(line);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!metaSent) {
|
|
159
|
+
stream.push({
|
|
160
|
+
v: PROTOCOL_VERSION,
|
|
161
|
+
type: MSG.META,
|
|
162
|
+
id: genId,
|
|
163
|
+
model: parsed.model ?? resolvedModel,
|
|
164
|
+
started_at: parsed.created_at ?? new Date().toISOString()
|
|
165
|
+
});
|
|
166
|
+
metaSent = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const text = parsed?.message?.content ?? "";
|
|
170
|
+
if (text) {
|
|
171
|
+
acc += text;
|
|
172
|
+
stream.push({
|
|
173
|
+
v: PROTOCOL_VERSION,
|
|
174
|
+
type: MSG.TOKEN,
|
|
175
|
+
id: genId,
|
|
176
|
+
text
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (parsed.done) {
|
|
181
|
+
stream.push({
|
|
182
|
+
v: PROTOCOL_VERSION,
|
|
183
|
+
type: MSG.DONE,
|
|
184
|
+
id: genId,
|
|
185
|
+
reason: parsed.done_reason ?? "stop",
|
|
186
|
+
text: acc,
|
|
187
|
+
finished_at: new Date().toISOString()
|
|
188
|
+
});
|
|
189
|
+
stream.end();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// body ended without a `done:true` chunk — surface as stop.
|
|
195
|
+
stream.push({
|
|
196
|
+
v: PROTOCOL_VERSION,
|
|
197
|
+
type: MSG.DONE,
|
|
198
|
+
id: genId,
|
|
199
|
+
reason: "stop",
|
|
200
|
+
text: acc,
|
|
201
|
+
finished_at: new Date().toISOString()
|
|
202
|
+
});
|
|
203
|
+
stream.end();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
pushTerminal(stream, genId, ctrl, err, acc);
|
|
206
|
+
}
|
|
207
|
+
})().catch((err) => {
|
|
208
|
+
stream.push({
|
|
209
|
+
v: PROTOCOL_VERSION,
|
|
210
|
+
type: MSG.ERROR,
|
|
211
|
+
id: genId,
|
|
212
|
+
message: err?.message ?? String(err)
|
|
213
|
+
});
|
|
214
|
+
stream.end();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
id: genId,
|
|
219
|
+
stream,
|
|
220
|
+
cancel: () => ctrl.abort()
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
async shutdown() {
|
|
224
|
+
// The Ollama daemon is owned by the operator, not by us.
|
|
225
|
+
// Nothing to clean up.
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pushTerminal(stream, genId, ctrl, err, accSoFar) {
|
|
231
|
+
if (ctrl.signal.aborted) {
|
|
232
|
+
stream.push({
|
|
233
|
+
v: PROTOCOL_VERSION,
|
|
234
|
+
type: MSG.DONE,
|
|
235
|
+
id: genId,
|
|
236
|
+
reason: "cancel",
|
|
237
|
+
text: accSoFar,
|
|
238
|
+
finished_at: new Date().toISOString()
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
stream.push({
|
|
242
|
+
v: PROTOCOL_VERSION,
|
|
243
|
+
type: MSG.ERROR,
|
|
244
|
+
id: genId,
|
|
245
|
+
message: err?.message ?? String(err)
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
stream.end();
|
|
249
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGLang backend — talks to a local SGLang server over the OpenAI-compatible
|
|
3
|
+
* chat completions endpoint.
|
|
4
|
+
*
|
|
5
|
+
* SGLang (Structured Generation Language) offers competitive throughput with
|
|
6
|
+
* vLLM (RadixAttention for KV-cache reuse, speculative decoding, jump-forward
|
|
7
|
+
* constrained decoding). Default port is 30000.
|
|
8
|
+
*
|
|
9
|
+
* Start: python -m sglang.launch_server --model-path <model> --port 30000
|
|
10
|
+
*
|
|
11
|
+
* Wire protocol is identical to vLLM (OpenAI SSE), so the streaming logic
|
|
12
|
+
* mirrors vllm.js exactly — only defaults differ.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { AsyncQueue } from "../async-queue.js";
|
|
17
|
+
import { MSG, PROTOCOL_VERSION } from "../protocol.js";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_HOST = "http://localhost:30000";
|
|
20
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
21
|
+
|
|
22
|
+
export const SGLANG_DEFAULT_HOST = DEFAULT_HOST;
|
|
23
|
+
|
|
24
|
+
export async function isSglangReachable(host = DEFAULT_HOST, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
25
|
+
try {
|
|
26
|
+
const ctrl = new AbortController();
|
|
27
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
28
|
+
const res = await fetch(new URL("/v1/models", host), { signal: ctrl.signal });
|
|
29
|
+
clearTimeout(t);
|
|
30
|
+
return res.ok;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createSglangBackend({
|
|
37
|
+
host = process.env.SGLANG_HOST ?? DEFAULT_HOST,
|
|
38
|
+
apiKey = process.env.SGLANG_API_KEY ?? null,
|
|
39
|
+
defaultModel = process.env.SGLANG_MODEL ?? process.env.INFERNET_ENGINE_MODEL ?? null,
|
|
40
|
+
skipProbe = false,
|
|
41
|
+
fetchImpl = globalThis.fetch
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (!skipProbe) {
|
|
44
|
+
const ok = await isSglangReachable(host);
|
|
45
|
+
if (!ok) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`SGLang not reachable at ${host} — start with 'python -m sglang.launch_server --model-path <model>' or set SGLANG_HOST`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
kind: "sglang",
|
|
54
|
+
host,
|
|
55
|
+
hasApiKey: Boolean(apiKey),
|
|
56
|
+
generate({ messages, id, model = null, max_tokens, temperature } = {}) {
|
|
57
|
+
const genId = id ?? randomUUID();
|
|
58
|
+
const stream = new AsyncQueue();
|
|
59
|
+
const ctrl = new AbortController();
|
|
60
|
+
const resolvedModel = model ?? defaultModel;
|
|
61
|
+
|
|
62
|
+
(async () => {
|
|
63
|
+
if (!resolvedModel) {
|
|
64
|
+
stream.push({
|
|
65
|
+
v: PROTOCOL_VERSION,
|
|
66
|
+
type: MSG.ERROR,
|
|
67
|
+
id: genId,
|
|
68
|
+
message: "sglang backend: no model — set SGLANG_MODEL or pass model in the job"
|
|
69
|
+
});
|
|
70
|
+
stream.end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const body = {
|
|
75
|
+
model: resolvedModel,
|
|
76
|
+
messages: Array.isArray(messages) ? messages : [],
|
|
77
|
+
stream: true
|
|
78
|
+
};
|
|
79
|
+
if (typeof temperature === "number") body.temperature = temperature;
|
|
80
|
+
if (typeof max_tokens === "number") body.max_tokens = max_tokens;
|
|
81
|
+
|
|
82
|
+
const headers = { "content-type": "application/json" };
|
|
83
|
+
if (apiKey) headers["authorization"] = `Bearer ${apiKey}`;
|
|
84
|
+
|
|
85
|
+
let res;
|
|
86
|
+
try {
|
|
87
|
+
res = await fetchImpl(new URL("/v1/chat/completions", host), {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers,
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
signal: ctrl.signal
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
pushTerminal(stream, genId, ctrl, err, "");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
let detail = "";
|
|
100
|
+
try { detail = await res.text(); } catch { /* best-effort */ }
|
|
101
|
+
stream.push({
|
|
102
|
+
v: PROTOCOL_VERSION,
|
|
103
|
+
type: MSG.ERROR,
|
|
104
|
+
id: genId,
|
|
105
|
+
message: `sglang HTTP ${res.status}: ${detail.slice(0, 200)}`
|
|
106
|
+
});
|
|
107
|
+
stream.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let metaSent = false;
|
|
112
|
+
let acc = "";
|
|
113
|
+
let buf = "";
|
|
114
|
+
let finishReason = null;
|
|
115
|
+
const decoder = new TextDecoder();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for await (const chunk of res.body) {
|
|
119
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
120
|
+
let nl;
|
|
121
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
122
|
+
const rawLine = buf.slice(0, nl);
|
|
123
|
+
buf = buf.slice(nl + 1);
|
|
124
|
+
const line = rawLine.trim();
|
|
125
|
+
if (!line || !line.startsWith("data:")) continue;
|
|
126
|
+
const payload = line.slice("data:".length).trim();
|
|
127
|
+
if (payload === "[DONE]") {
|
|
128
|
+
stream.push({
|
|
129
|
+
v: PROTOCOL_VERSION,
|
|
130
|
+
type: MSG.DONE,
|
|
131
|
+
id: genId,
|
|
132
|
+
reason: finishReason ?? "stop",
|
|
133
|
+
text: acc,
|
|
134
|
+
finished_at: new Date().toISOString()
|
|
135
|
+
});
|
|
136
|
+
stream.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let parsed;
|
|
140
|
+
try { parsed = JSON.parse(payload); } catch { continue; }
|
|
141
|
+
|
|
142
|
+
if (!metaSent) {
|
|
143
|
+
stream.push({
|
|
144
|
+
v: PROTOCOL_VERSION,
|
|
145
|
+
type: MSG.META,
|
|
146
|
+
id: genId,
|
|
147
|
+
model: parsed.model ?? resolvedModel,
|
|
148
|
+
started_at: new Date().toISOString()
|
|
149
|
+
});
|
|
150
|
+
metaSent = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
|
|
154
|
+
const text = choice?.delta?.content ?? "";
|
|
155
|
+
if (text) {
|
|
156
|
+
acc += text;
|
|
157
|
+
stream.push({ v: PROTOCOL_VERSION, type: MSG.TOKEN, id: genId, text });
|
|
158
|
+
}
|
|
159
|
+
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
stream.push({
|
|
163
|
+
v: PROTOCOL_VERSION,
|
|
164
|
+
type: MSG.DONE,
|
|
165
|
+
id: genId,
|
|
166
|
+
reason: finishReason ?? "stop",
|
|
167
|
+
text: acc,
|
|
168
|
+
finished_at: new Date().toISOString()
|
|
169
|
+
});
|
|
170
|
+
stream.end();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
pushTerminal(stream, genId, ctrl, err, acc);
|
|
173
|
+
}
|
|
174
|
+
})().catch((err) => {
|
|
175
|
+
stream.push({ v: PROTOCOL_VERSION, type: MSG.ERROR, id: genId, message: err?.message ?? String(err) });
|
|
176
|
+
stream.end();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return { id: genId, stream, cancel: () => ctrl.abort() };
|
|
180
|
+
},
|
|
181
|
+
async shutdown() {}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function pushTerminal(stream, genId, ctrl, err, accSoFar) {
|
|
186
|
+
if (ctrl.signal.aborted) {
|
|
187
|
+
stream.push({ v: PROTOCOL_VERSION, type: MSG.DONE, id: genId, reason: "cancel", text: accSoFar, finished_at: new Date().toISOString() });
|
|
188
|
+
} else {
|
|
189
|
+
stream.push({ v: PROTOCOL_VERSION, type: MSG.ERROR, id: genId, message: err?.message ?? String(err) });
|
|
190
|
+
}
|
|
191
|
+
stream.end();
|
|
192
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub backend — produces canned tokens in-process, no child binary.
|
|
3
|
+
*
|
|
4
|
+
* This is the baseline so the daemon works on every box without a Mojo
|
|
5
|
+
* toolchain. It speaks the same async-iterator interface as the Mojo
|
|
6
|
+
* backend, so callers (e.g. chat-executor) are backend-agnostic.
|
|
7
|
+
*
|
|
8
|
+
* The token stream is the same one the daemon shipped before the engine
|
|
9
|
+
* abstraction landed — keeping observable behavior unchanged when no real
|
|
10
|
+
* engine is configured.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { AsyncQueue } from "../async-queue.js";
|
|
15
|
+
import { MSG, PROTOCOL_VERSION } from "../protocol.js";
|
|
16
|
+
|
|
17
|
+
const STUB_RESPONSE_TEMPLATE = (input) =>
|
|
18
|
+
[
|
|
19
|
+
`Running on the Infernet P2P network.`,
|
|
20
|
+
`You said: "${String(input).slice(0, 200)}"`,
|
|
21
|
+
`This is a stub response from the provider daemon — no real model is running yet.`,
|
|
22
|
+
`When a real engine (Mojo + MAX) is wired in, tokens will stream from actual inference.`
|
|
23
|
+
].join(" ");
|
|
24
|
+
|
|
25
|
+
function lastUserMessage(messages) {
|
|
26
|
+
if (!Array.isArray(messages)) return "";
|
|
27
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
28
|
+
const m = messages[i];
|
|
29
|
+
if (m?.role === "user" && typeof m.content === "string") return m.content;
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function createStubBackend({ tokenDelayMs = 60 } = {}) {
|
|
35
|
+
return {
|
|
36
|
+
kind: "stub",
|
|
37
|
+
generate({ messages, id, model = null } = {}) {
|
|
38
|
+
const genId = id ?? randomUUID();
|
|
39
|
+
const stream = new AsyncQueue();
|
|
40
|
+
const userText = lastUserMessage(messages);
|
|
41
|
+
const fullText = STUB_RESPONSE_TEMPLATE(userText);
|
|
42
|
+
const tokens = fullText.split(/(\s+)/).filter(Boolean);
|
|
43
|
+
|
|
44
|
+
(async () => {
|
|
45
|
+
stream.push({
|
|
46
|
+
v: PROTOCOL_VERSION,
|
|
47
|
+
type: MSG.META,
|
|
48
|
+
id: genId,
|
|
49
|
+
model,
|
|
50
|
+
started_at: new Date().toISOString()
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let acc = "";
|
|
54
|
+
for (const tok of tokens) {
|
|
55
|
+
if (stream.closed) return;
|
|
56
|
+
acc += tok;
|
|
57
|
+
stream.push({
|
|
58
|
+
v: PROTOCOL_VERSION,
|
|
59
|
+
type: MSG.TOKEN,
|
|
60
|
+
id: genId,
|
|
61
|
+
text: tok
|
|
62
|
+
});
|
|
63
|
+
const jitter = Math.floor(Math.random() * tokenDelayMs);
|
|
64
|
+
await new Promise((r) => setTimeout(r, tokenDelayMs + jitter));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stream.push({
|
|
68
|
+
v: PROTOCOL_VERSION,
|
|
69
|
+
type: MSG.DONE,
|
|
70
|
+
id: genId,
|
|
71
|
+
reason: "stop",
|
|
72
|
+
text: acc,
|
|
73
|
+
finished_at: new Date().toISOString()
|
|
74
|
+
});
|
|
75
|
+
stream.end();
|
|
76
|
+
})().catch((err) => {
|
|
77
|
+
stream.push({
|
|
78
|
+
v: PROTOCOL_VERSION,
|
|
79
|
+
type: MSG.ERROR,
|
|
80
|
+
id: genId,
|
|
81
|
+
message: err?.message ?? String(err)
|
|
82
|
+
});
|
|
83
|
+
stream.end();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: genId,
|
|
88
|
+
stream,
|
|
89
|
+
cancel: () => stream.end()
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
async shutdown() {
|
|
93
|
+
// nothing to clean up
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|