@melaya/runner 1.0.17 → 1.0.18
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/dist/connection.js +96 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -197,6 +197,102 @@ export async function connect(opts) {
|
|
|
197
197
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
198
198
|
}
|
|
199
199
|
});
|
|
200
|
+
// ── LLM stream relay ───────────────────────────────────────────────
|
|
201
|
+
//
|
|
202
|
+
// The cloud server picked the user's local Ollama / LM Studio model
|
|
203
|
+
// for an AI feature (Build-with-AI architect, etc.) — but the LLM
|
|
204
|
+
// server only exists on this machine, so we proxy the chat-completion
|
|
205
|
+
// here: fetch the local URL, stream chunks back over the existing
|
|
206
|
+
// socket. The server-side `streamLLMViaRunner()` consumes them.
|
|
207
|
+
//
|
|
208
|
+
// Protocol:
|
|
209
|
+
// server → runner: runner:llm-stream-request { reqId, url, headers, body }
|
|
210
|
+
// runner → server: runner:llm-stream-chunk { reqId, chunk: string }
|
|
211
|
+
// runner → server: runner:llm-stream-end { reqId, status }
|
|
212
|
+
// runner → server: runner:llm-stream-error { reqId, error }
|
|
213
|
+
// server → runner: runner:llm-stream-cancel { reqId }
|
|
214
|
+
const _activeLLMStreams = new Map();
|
|
215
|
+
socket.on("runner:llm-stream-request", async (req) => {
|
|
216
|
+
const reqId = req?.reqId;
|
|
217
|
+
if (!reqId || typeof req.url !== "string")
|
|
218
|
+
return;
|
|
219
|
+
// Defense-in-depth: only allow loopback URLs. The server already
|
|
220
|
+
// generates these as `http://localhost:1234` / `http://localhost:11434`,
|
|
221
|
+
// but enforcing it here means a compromised server connection can't
|
|
222
|
+
// pivot the runner into making outbound requests on its behalf.
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = new URL(req.url);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "invalid_url" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") {
|
|
232
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "non_loopback_url_rejected" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (opts.verbose)
|
|
236
|
+
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} → ${req.url}`));
|
|
237
|
+
const ac = new AbortController();
|
|
238
|
+
_activeLLMStreams.set(reqId, ac);
|
|
239
|
+
try {
|
|
240
|
+
const upstream = await fetch(req.url, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: req.headers || { "content-type": "application/json" },
|
|
243
|
+
body: req.body || "",
|
|
244
|
+
signal: ac.signal,
|
|
245
|
+
});
|
|
246
|
+
if (!upstream.body) {
|
|
247
|
+
const text = await upstream.text().catch(() => "");
|
|
248
|
+
socket.emit("runner:llm-stream-error", {
|
|
249
|
+
reqId,
|
|
250
|
+
error: `upstream_no_body status=${upstream.status} ${text.slice(0, 200)}`,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Pipe chunks back as decoded text. The cloud server's parser is
|
|
255
|
+
// text-oriented (SSE `data:` lines) so we decode here and ship
|
|
256
|
+
// strings rather than base64 — keeps the WS payload small + lets
|
|
257
|
+
// the server keep its existing line-buffer code path.
|
|
258
|
+
const reader = upstream.body.getReader();
|
|
259
|
+
const decoder = new TextDecoder();
|
|
260
|
+
while (true) {
|
|
261
|
+
const { value, done } = await reader.read();
|
|
262
|
+
if (done)
|
|
263
|
+
break;
|
|
264
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
265
|
+
if (chunk.length > 0) {
|
|
266
|
+
socket.emit("runner:llm-stream-chunk", { reqId, chunk });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Flush any tail bytes.
|
|
270
|
+
const tail = decoder.decode();
|
|
271
|
+
if (tail.length > 0)
|
|
272
|
+
socket.emit("runner:llm-stream-chunk", { reqId, chunk: tail });
|
|
273
|
+
socket.emit("runner:llm-stream-end", { reqId, status: upstream.status });
|
|
274
|
+
if (opts.verbose)
|
|
275
|
+
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} ◄ end ${upstream.status}`));
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
if (e?.name === "AbortError") {
|
|
279
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "aborted" });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
socket.emit("runner:llm-stream-error", { reqId, error: String(e?.message || e) });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
_activeLLMStreams.delete(reqId);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
socket.on("runner:llm-stream-cancel", (req) => {
|
|
290
|
+
const ac = _activeLLMStreams.get(req?.reqId);
|
|
291
|
+
if (ac) {
|
|
292
|
+
ac.abort();
|
|
293
|
+
_activeLLMStreams.delete(req.reqId);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
200
296
|
// ── Graceful shutdown ──────────────────────────────────────────────
|
|
201
297
|
const cleanup = () => {
|
|
202
298
|
console.log(chalk.gray("\n Shutting down..."));
|