sublera-cli 0.0.0-stage → 1.3.0
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/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +585 -2
- package/SECURITY.md +78 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent.d.ts +270 -0
- package/dist/agent.js +1867 -0
- package/dist/commands.d.ts +76 -0
- package/dist/commands.js +841 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +95 -0
- package/dist/engine/binaryManager.d.ts +67 -0
- package/dist/engine/binaryManager.js +590 -0
- package/dist/engine/gguf.d.ts +88 -0
- package/dist/engine/gguf.js +458 -0
- package/dist/engine/llamaRunner.d.ts +78 -0
- package/dist/engine/llamaRunner.js +282 -0
- package/dist/engine/modelManager.d.ts +63 -0
- package/dist/engine/modelManager.js +510 -0
- package/dist/engine/selfCheck.d.ts +34 -0
- package/dist/engine/selfCheck.js +108 -0
- package/dist/exitCodes.d.ts +20 -0
- package/dist/exitCodes.js +20 -0
- package/dist/hardware/gpuMonitor.d.ts +32 -0
- package/dist/hardware/gpuMonitor.js +378 -0
- package/dist/hardware/sleepGuard.d.ts +42 -0
- package/dist/hardware/sleepGuard.js +149 -0
- package/dist/hardware/types.d.ts +40 -0
- package/dist/hardware/types.js +2 -0
- package/dist/health.d.ts +17 -0
- package/dist/health.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +268 -0
- package/dist/logger.d.ts +53 -0
- package/dist/logger.js +201 -0
- package/dist/network/proxy.d.ts +58 -0
- package/dist/network/proxy.js +328 -0
- package/dist/network/streamProxy.d.ts +51 -0
- package/dist/network/streamProxy.js +286 -0
- package/dist/network/tunnelClient.d.ts +54 -0
- package/dist/network/tunnelClient.js +257 -0
- package/dist/settings.d.ts +53 -0
- package/dist/settings.js +171 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +71 -0
- package/dist/storageCommand.d.ts +22 -0
- package/dist/storageCommand.js +81 -0
- package/dist/types.d.ts +222 -0
- package/dist/types.js +43 -0
- package/dist/ui/progress.d.ts +29 -0
- package/dist/ui/progress.js +82 -0
- package/dist/ui/terminalDashboard.d.ts +88 -0
- package/dist/ui/terminalDashboard.js +295 -0
- package/dist/util/env.d.ts +3 -0
- package/dist/util/env.js +6 -0
- package/dist/util/exec.d.ts +23 -0
- package/dist/util/exec.js +116 -0
- package/dist/util/format.d.ts +47 -0
- package/dist/util/format.js +125 -0
- package/dist/util/fs.d.ts +18 -0
- package/dist/util/fs.js +140 -0
- package/dist/util/http.d.ts +28 -0
- package/dist/util/http.js +128 -0
- package/dist/util/instanceLock.d.ts +3 -0
- package/dist/util/instanceLock.js +108 -0
- package/dist/util/open.d.ts +16 -0
- package/dist/util/open.js +65 -0
- package/dist/util/storage.d.ts +10 -0
- package/dist/util/storage.js +69 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +29 -0
- package/package.json +68 -4
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Agent as HttpAgent, request as httpRequest } from 'node:http';
|
|
2
|
+
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
|
|
3
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
function envValue(env, name) {
|
|
6
|
+
const value = env[name] ?? env[name.toLowerCase()];
|
|
7
|
+
return value && value.trim() ? value.trim() : null;
|
|
8
|
+
}
|
|
9
|
+
export function proxySettings(env = process.env) {
|
|
10
|
+
return {
|
|
11
|
+
httpsProxy: envValue(env, 'HTTPS_PROXY'),
|
|
12
|
+
httpProxy: envValue(env, 'HTTP_PROXY'),
|
|
13
|
+
noProxy: (envValue(env, 'NO_PROXY') || '').split(/[\s,]+/).map((v) => v.trim().toLowerCase()).filter(Boolean),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function proxyConfigured(settings = proxySettings()) {
|
|
17
|
+
return Boolean(settings.httpsProxy || settings.httpProxy);
|
|
18
|
+
}
|
|
19
|
+
/** True when NO_PROXY (or loopback) says this host must be reached directly. */
|
|
20
|
+
export function bypassProxy(target, noProxy) {
|
|
21
|
+
const host = normalizeHost(target.hostname);
|
|
22
|
+
if (host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.')) ||
|
|
23
|
+
/^::ffff:7f[0-9a-f]{2}:/i.test(host))
|
|
24
|
+
return true;
|
|
25
|
+
const port = target.port || (target.protocol === 'https:' || target.protocol === 'wss:' ? '443' : '80');
|
|
26
|
+
for (const raw of noProxy) {
|
|
27
|
+
let entry = raw.trim().toLowerCase();
|
|
28
|
+
if (entry === '*')
|
|
29
|
+
return true;
|
|
30
|
+
// Only bracketed IPv6 may have a port; a bare IPv6 address is never split.
|
|
31
|
+
const match = entry.match(/^(\[[^\]]+\]|[^:]+):(\d+)$/);
|
|
32
|
+
if (match) {
|
|
33
|
+
if (Number(match[2]) !== Number(port))
|
|
34
|
+
continue;
|
|
35
|
+
entry = match[1];
|
|
36
|
+
}
|
|
37
|
+
entry = normalizeHost(entry.replace(/^\*?\./, ''));
|
|
38
|
+
if (entry && (host === entry || (!isIP(host) && !isIP(entry) && host.endsWith(`.${entry}`))))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function normalizeHost(host) {
|
|
44
|
+
const bare = host.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
45
|
+
if (isIP(bare) === 6 && !bare.includes('%'))
|
|
46
|
+
return new URL(`http://[${bare}]`).hostname.slice(1, -1);
|
|
47
|
+
return bare;
|
|
48
|
+
}
|
|
49
|
+
/** The proxy URL to use for a target, or null for a direct connection. */
|
|
50
|
+
export function proxyFor(target, settings = proxySettings()) {
|
|
51
|
+
const url = typeof target === 'string' ? new URL(target) : target;
|
|
52
|
+
if (bypassProxy(url, settings.noProxy))
|
|
53
|
+
return null;
|
|
54
|
+
const secure = url.protocol === 'https:' || url.protocol === 'wss:';
|
|
55
|
+
const raw = secure ? settings.httpsProxy || settings.httpProxy : settings.httpProxy;
|
|
56
|
+
if (!raw)
|
|
57
|
+
return null;
|
|
58
|
+
return parseProxyUrl(raw);
|
|
59
|
+
}
|
|
60
|
+
export function parseProxyUrl(raw) {
|
|
61
|
+
let proxy;
|
|
62
|
+
try {
|
|
63
|
+
proxy = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('Invalid proxy setting (use an http:// or https:// proxy URL)');
|
|
67
|
+
}
|
|
68
|
+
if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
|
|
69
|
+
throw new Error('Unsupported proxy protocol (use an http:// or https:// proxy)');
|
|
70
|
+
}
|
|
71
|
+
// Fail eagerly and without echoing credentials, including malformed escapes.
|
|
72
|
+
proxyAuthHeader(proxy);
|
|
73
|
+
return proxy;
|
|
74
|
+
}
|
|
75
|
+
/** Proxy URL safe for logs and doctor output: credentials are hidden. */
|
|
76
|
+
export function redactProxy(raw) {
|
|
77
|
+
if (!raw)
|
|
78
|
+
return 'none';
|
|
79
|
+
try {
|
|
80
|
+
const url = parseProxyUrl(String(raw));
|
|
81
|
+
// Paths, query strings and fragments can also contain secrets.
|
|
82
|
+
return `${url.protocol}//${url.username || url.password ? '***@' : ''}${url.host}`;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return '[invalid proxy URL]';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function proxyAuthHeader(proxy) {
|
|
89
|
+
if (!proxy.username && !proxy.password)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const user = decodeURIComponent(proxy.username);
|
|
93
|
+
const pass = decodeURIComponent(proxy.password);
|
|
94
|
+
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error('Invalid proxy credentials (check percent-encoding)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Opens an HTTP CONNECT tunnel through the proxy and returns the raw socket to host:port. */
|
|
101
|
+
export function openConnectTunnel(proxy, host, port, timeoutMs = 15_000, signal) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
let req;
|
|
104
|
+
let settled = false;
|
|
105
|
+
let timer;
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
signal?.removeEventListener('abort', abort);
|
|
109
|
+
};
|
|
110
|
+
const fail = (error) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
114
|
+
cleanup();
|
|
115
|
+
req?.destroy();
|
|
116
|
+
reject(error);
|
|
117
|
+
};
|
|
118
|
+
const abort = () => fail(new Error('Proxy connection aborted'));
|
|
119
|
+
const refused = (status) => {
|
|
120
|
+
const hint = status === 407 ? ' (the proxy needs a login: use http://user:password@proxy:port)' : '';
|
|
121
|
+
return new Error(`Proxy ${redactProxy(proxy)} refused CONNECT: HTTP ${status}${hint}`);
|
|
122
|
+
};
|
|
123
|
+
if (signal?.aborted) {
|
|
124
|
+
abort();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
proxy = parseProxyUrl(proxy.href);
|
|
129
|
+
host = normalizeHost(host);
|
|
130
|
+
if (!host || /[\s/?#@]/.test(host) || (host.includes(':') && !isIP(host)) ||
|
|
131
|
+
!Number.isInteger(port) || port < 1 || port > 65535)
|
|
132
|
+
throw new Error('Invalid CONNECT target');
|
|
133
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
|
134
|
+
const headers = { host: authority };
|
|
135
|
+
const auth = proxyAuthHeader(proxy);
|
|
136
|
+
if (auth)
|
|
137
|
+
headers['proxy-authorization'] = auth;
|
|
138
|
+
const request = proxy.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
139
|
+
const proxyHost = proxy.hostname.replace(/^\[|\]$/g, '');
|
|
140
|
+
req = request({
|
|
141
|
+
host: proxyHost,
|
|
142
|
+
// HTTPS otherwise derives SNI/verification from the CONNECT Host header,
|
|
143
|
+
// which names the destination rather than the TLS peer (the proxy).
|
|
144
|
+
servername: isIP(proxyHost) ? '' : proxyHost,
|
|
145
|
+
port: Number(proxy.port) || (proxy.protocol === 'https:' ? 443 : 80),
|
|
146
|
+
method: 'CONNECT',
|
|
147
|
+
path: authority,
|
|
148
|
+
headers,
|
|
149
|
+
agent: false,
|
|
150
|
+
});
|
|
151
|
+
req.once('connect', (res, socket, head) => {
|
|
152
|
+
if (settled || res.statusCode !== 200) {
|
|
153
|
+
socket.destroy();
|
|
154
|
+
if (!settled)
|
|
155
|
+
fail(refused(res.statusCode));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
settled = true;
|
|
159
|
+
cleanup();
|
|
160
|
+
socket.setTimeout(0);
|
|
161
|
+
// The proxy may send upstream bytes in the same packet as its headers.
|
|
162
|
+
if (head.length)
|
|
163
|
+
socket.unshift(head);
|
|
164
|
+
resolve(socket);
|
|
165
|
+
});
|
|
166
|
+
req.once('response', (res) => { res.destroy(); fail(refused(res.statusCode)); });
|
|
167
|
+
req.once('close', () => fail(new Error(`Proxy ${redactProxy(proxy)} closed before CONNECT completed`)));
|
|
168
|
+
// Do not echo peer-controlled parser errors (which can include credentials).
|
|
169
|
+
req.once('error', (error) => {
|
|
170
|
+
const code = error.code && /^[A-Z0-9_]+$/.test(error.code) ? ` (${error.code})` : '';
|
|
171
|
+
fail(new Error(`Could not reach proxy ${redactProxy(proxy)}${code}`));
|
|
172
|
+
});
|
|
173
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
174
|
+
// A deadline, not an inactivity timeout: trickled headers must not hang forever.
|
|
175
|
+
timer = setTimeout(() => fail(new Error(`Proxy ${redactProxy(proxy)} did not answer within ${timeoutMs}ms`)), timeoutMs);
|
|
176
|
+
timer.unref();
|
|
177
|
+
req.end();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
fail(new Error(`Invalid proxy connection settings for ${redactProxy(proxy)}`));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Opens the CONNECT tunnel, then (for https/wss) does TLS end to end with the real host. */
|
|
185
|
+
function connectThroughProxy(proxy, secure, rawOptions, pending, callback) {
|
|
186
|
+
// An asynchronous connection cannot be handed off without a callback.
|
|
187
|
+
if (!callback)
|
|
188
|
+
return;
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
pending.add(controller);
|
|
191
|
+
const options = rawOptions;
|
|
192
|
+
const host = String(options.hostname || options.host || 'localhost').replace(/^\[|\]$/g, '');
|
|
193
|
+
const port = Number(options.port) || (secure ? 443 : 80);
|
|
194
|
+
const timeout = options.timeout && options.timeout > 0 ? options.timeout : 15_000;
|
|
195
|
+
let stream;
|
|
196
|
+
let settled = false;
|
|
197
|
+
let timer;
|
|
198
|
+
const finish = (error) => {
|
|
199
|
+
if (settled)
|
|
200
|
+
return;
|
|
201
|
+
settled = true;
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
controller.signal.removeEventListener('abort', abort);
|
|
204
|
+
pending.delete(controller);
|
|
205
|
+
stream?.removeListener('secureConnect', ready);
|
|
206
|
+
stream?.removeListener('error', fail);
|
|
207
|
+
stream?.removeListener('close', closed);
|
|
208
|
+
if (error)
|
|
209
|
+
stream?.destroy();
|
|
210
|
+
// Node accepts an error with no stream; the typings require one.
|
|
211
|
+
callback(error, error ? undefined : stream);
|
|
212
|
+
};
|
|
213
|
+
const fail = (error) => finish(error);
|
|
214
|
+
const ready = () => finish(null);
|
|
215
|
+
const closed = () => fail(new Error('Proxy tunnel closed before TLS negotiation completed'));
|
|
216
|
+
const abort = () => fail(new Error('Proxy connection aborted'));
|
|
217
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
218
|
+
openConnectTunnel(proxy, host, port, timeout, controller.signal).then((socket) => {
|
|
219
|
+
if (settled) {
|
|
220
|
+
socket.destroy();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
stream = socket;
|
|
224
|
+
if (!secure) {
|
|
225
|
+
ready();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
// Always validate against the destination, not the proxy or localhost.
|
|
230
|
+
// IP literals need certificate IP validation but must not be sent as SNI.
|
|
231
|
+
stream = tlsConnect({ ...options, host, port, socket, servername: options.servername ?? (isIP(host) ? '' : host) });
|
|
232
|
+
stream.once('secureConnect', ready);
|
|
233
|
+
stream.once('error', fail);
|
|
234
|
+
stream.once('close', closed);
|
|
235
|
+
timer = setTimeout(() => fail(new Error('Proxy tunnel TLS negotiation timed out')), timeout);
|
|
236
|
+
timer.unref();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
fail(new Error('Could not initialize TLS through proxy'));
|
|
240
|
+
}
|
|
241
|
+
}, fail);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* An https.Agent that reaches the target through an HTTP CONNECT proxy. Used
|
|
245
|
+
* for the wss:// tunnel, because the `ws` package takes a Node agent and ignores proxy variables.
|
|
246
|
+
*/
|
|
247
|
+
export class ConnectProxyAgent extends HttpsAgent {
|
|
248
|
+
proxy;
|
|
249
|
+
pending = new Set();
|
|
250
|
+
constructor(proxy, options = {}) {
|
|
251
|
+
super({ keepAlive: false, ...options });
|
|
252
|
+
this.proxy = proxy;
|
|
253
|
+
}
|
|
254
|
+
createConnection(rawOptions, callback) {
|
|
255
|
+
connectThroughProxy(this.proxy, true, rawOptions, this.pending, callback);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
destroy() {
|
|
259
|
+
for (const controller of this.pending)
|
|
260
|
+
controller.abort();
|
|
261
|
+
super.destroy();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** The plain-HTTP twin of ConnectProxyAgent, for ws:// servers (local or test setups). */
|
|
265
|
+
export class ConnectProxyHttpAgent extends HttpAgent {
|
|
266
|
+
proxy;
|
|
267
|
+
pending = new Set();
|
|
268
|
+
constructor(proxy) {
|
|
269
|
+
super({ keepAlive: false });
|
|
270
|
+
this.proxy = proxy;
|
|
271
|
+
}
|
|
272
|
+
createConnection(rawOptions, callback) {
|
|
273
|
+
connectThroughProxy(this.proxy, false, rawOptions, this.pending, callback);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
destroy() {
|
|
277
|
+
for (const controller of this.pending)
|
|
278
|
+
controller.abort();
|
|
279
|
+
super.destroy();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** A Node agent for the `ws` tunnel when a proxy applies to this URL, else undefined (direct). */
|
|
283
|
+
export function websocketAgentFor(url, settings = proxySettings()) {
|
|
284
|
+
const proxy = proxyFor(url, settings);
|
|
285
|
+
if (!proxy)
|
|
286
|
+
return undefined;
|
|
287
|
+
return /^(wss|https):/i.test(url) ? new ConnectProxyAgent(proxy) : new ConnectProxyHttpAgent(proxy);
|
|
288
|
+
}
|
|
289
|
+
let fetchProxyInstallation;
|
|
290
|
+
/**
|
|
291
|
+
* Routes the global fetch() (API calls, model and engine downloads) through the
|
|
292
|
+
* configured proxy. Loads nothing and changes nothing when no proxy is set.
|
|
293
|
+
*/
|
|
294
|
+
export async function installFetchProxy(env = process.env) {
|
|
295
|
+
if (fetchProxyInstallation)
|
|
296
|
+
return fetchProxyInstallation;
|
|
297
|
+
const settings = proxySettings(env);
|
|
298
|
+
if (!proxyConfigured(settings))
|
|
299
|
+
return false;
|
|
300
|
+
// A malformed value fails here with a clear message instead of on the first download.
|
|
301
|
+
if (settings.httpsProxy)
|
|
302
|
+
settings.httpsProxy = parseProxyUrl(settings.httpsProxy).href;
|
|
303
|
+
if (settings.httpProxy)
|
|
304
|
+
settings.httpProxy = parseProxyUrl(settings.httpProxy).href;
|
|
305
|
+
fetchProxyInstallation = (async () => {
|
|
306
|
+
const undici = await import('undici');
|
|
307
|
+
// Node's native fetch shares this dispatcher. Do not replace fetch, Request,
|
|
308
|
+
// Response, FormData, WebSocket, etc. with different class identities.
|
|
309
|
+
undici.setGlobalDispatcher(new undici.Agent({
|
|
310
|
+
factory(origin, options) {
|
|
311
|
+
// One routing policy for fetch and ws; no ambient environment fallback.
|
|
312
|
+
const proxy = proxyFor(String(origin), settings);
|
|
313
|
+
return proxy
|
|
314
|
+
? new undici.ProxyAgent({ ...options, uri: proxy.href, token: proxyAuthHeader(proxy) ?? undefined })
|
|
315
|
+
: new undici.Pool(origin, options);
|
|
316
|
+
},
|
|
317
|
+
}));
|
|
318
|
+
return true;
|
|
319
|
+
})();
|
|
320
|
+
try {
|
|
321
|
+
return await fetchProxyInstallation;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
fetchProxyInstallation = undefined;
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ChatMessage, TokenPosition } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Token probabilities go out with the first positions of every job, so the server can check the model behind any job
|
|
4
|
+
* (its model checks look exactly like normal work). Later tokens are sent without them to keep frames small.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PROBED_POSITIONS = 32;
|
|
7
|
+
export type ProxyJob = {
|
|
8
|
+
jobId: string;
|
|
9
|
+
messages: ChatMessage[];
|
|
10
|
+
maxTokens: number;
|
|
11
|
+
temperature: number;
|
|
12
|
+
model?: string;
|
|
13
|
+
seed?: number;
|
|
14
|
+
};
|
|
15
|
+
export type StreamResult = {
|
|
16
|
+
text: string;
|
|
17
|
+
finishReason: string;
|
|
18
|
+
promptTokens: number;
|
|
19
|
+
completionTokens: number;
|
|
20
|
+
usage: {
|
|
21
|
+
prompt_tokens: number;
|
|
22
|
+
completion_tokens: number;
|
|
23
|
+
total_tokens: number;
|
|
24
|
+
} | null;
|
|
25
|
+
ttftMs: number | null;
|
|
26
|
+
tokPerSec: number | null;
|
|
27
|
+
};
|
|
28
|
+
/** `positions` holds the probabilities of the tokens in this chunk, for the first PROBED_POSITIONS tokens only. */
|
|
29
|
+
export type ChunkHandler = (text: string, positions: TokenPosition[]) => void;
|
|
30
|
+
export type StreamOptions = {
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
onChunk?: ChunkHandler;
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
};
|
|
36
|
+
export declare class SseParser {
|
|
37
|
+
private buffer;
|
|
38
|
+
private dataLines;
|
|
39
|
+
private eventSize;
|
|
40
|
+
push(text: string): string[];
|
|
41
|
+
flush(): string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function streamChat(llamaUrl: string, job: ProxyJob, options?: StreamOptions): Promise<StreamResult>;
|
|
44
|
+
export declare function simulateReply(prompt: string, model: string): string;
|
|
45
|
+
/** Stand-in probabilities for test nodes: the same shape as llama-server's, so the server's checks run end to end. */
|
|
46
|
+
export declare function simulatedPosition(token: string): TokenPosition;
|
|
47
|
+
export declare function simulateChat(job: ProxyJob, options?: {
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
tokPerSec?: number;
|
|
50
|
+
onChunk?: ChunkHandler;
|
|
51
|
+
}): Promise<StreamResult>;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token probabilities go out with the first positions of every job, so the server can check the model behind any job
|
|
3
|
+
* (its model checks look exactly like normal work). Later tokens are sent without them to keep frames small.
|
|
4
|
+
*/
|
|
5
|
+
export const PROBED_POSITIONS = 32;
|
|
6
|
+
const TOP_ALTERNATIVES = 5;
|
|
7
|
+
export class SseParser {
|
|
8
|
+
buffer = '';
|
|
9
|
+
dataLines = [];
|
|
10
|
+
eventSize = 0;
|
|
11
|
+
push(text) {
|
|
12
|
+
this.buffer += text;
|
|
13
|
+
if (this.buffer.length > 2 * 1024 * 1024)
|
|
14
|
+
throw new Error('llama-server SSE buffer exceeds limit');
|
|
15
|
+
const events = [];
|
|
16
|
+
while (true) {
|
|
17
|
+
const match = this.buffer.match(/\r?\n/);
|
|
18
|
+
if (!match || match.index == null)
|
|
19
|
+
break;
|
|
20
|
+
const line = this.buffer.slice(0, match.index);
|
|
21
|
+
this.buffer = this.buffer.slice(match.index + match[0].length);
|
|
22
|
+
if (line === '') {
|
|
23
|
+
if (this.dataLines.length) {
|
|
24
|
+
events.push(this.dataLines.join('\n'));
|
|
25
|
+
this.dataLines = [];
|
|
26
|
+
this.eventSize = 0;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (line.startsWith(':'))
|
|
31
|
+
continue;
|
|
32
|
+
if (line.startsWith('data:')) {
|
|
33
|
+
this.eventSize += line.length;
|
|
34
|
+
if (this.eventSize > 1024 * 1024)
|
|
35
|
+
throw new Error('llama-server SSE event exceeds limit');
|
|
36
|
+
this.dataLines.push(line.slice(5).trimStart());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return events;
|
|
40
|
+
}
|
|
41
|
+
flush() {
|
|
42
|
+
const events = [];
|
|
43
|
+
if (this.buffer.startsWith('data:'))
|
|
44
|
+
this.dataLines.push(this.buffer.slice(5).trimStart());
|
|
45
|
+
this.buffer = '';
|
|
46
|
+
if (this.dataLines.length) {
|
|
47
|
+
events.push(this.dataLines.join('\n'));
|
|
48
|
+
this.dataLines = [];
|
|
49
|
+
this.eventSize = 0;
|
|
50
|
+
}
|
|
51
|
+
return events;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function deltaContent(value) {
|
|
55
|
+
if (typeof value === 'string')
|
|
56
|
+
return value;
|
|
57
|
+
if (!Array.isArray(value))
|
|
58
|
+
return '';
|
|
59
|
+
return value.map((part) => {
|
|
60
|
+
if (typeof part === 'string')
|
|
61
|
+
return part;
|
|
62
|
+
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string')
|
|
63
|
+
return part.text;
|
|
64
|
+
return '';
|
|
65
|
+
}).join('');
|
|
66
|
+
}
|
|
67
|
+
function asNumber(value) {
|
|
68
|
+
return typeof value === 'number' && Number.isFinite(value) && value <= 0 && value > -9999 ? value : null;
|
|
69
|
+
}
|
|
70
|
+
function toPosition(entry) {
|
|
71
|
+
if (!entry || typeof entry !== 'object')
|
|
72
|
+
return null;
|
|
73
|
+
const l = asNumber(entry.logprob);
|
|
74
|
+
if (typeof entry.token !== 'string' || l == null)
|
|
75
|
+
return null;
|
|
76
|
+
const top = [];
|
|
77
|
+
for (const alt of Array.isArray(entry.top_logprobs) ? entry.top_logprobs.slice(0, TOP_ALTERNATIVES) : []) {
|
|
78
|
+
if (!alt || typeof alt !== 'object')
|
|
79
|
+
continue;
|
|
80
|
+
const value = asNumber(alt.logprob);
|
|
81
|
+
if (typeof alt.token === 'string' && value != null)
|
|
82
|
+
top.push([alt.token, value]);
|
|
83
|
+
}
|
|
84
|
+
return { t: entry.token, l, top };
|
|
85
|
+
}
|
|
86
|
+
export async function streamChat(llamaUrl, job, options = {}) {
|
|
87
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timeout = setTimeout(() => controller.abort(new Error('Generation timed out')), options.timeoutMs ?? 10 * 60_000);
|
|
90
|
+
const onAbort = () => controller.abort(options.signal?.reason);
|
|
91
|
+
options.signal?.addEventListener('abort', onAbort);
|
|
92
|
+
if (options.signal?.aborted)
|
|
93
|
+
onAbort();
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
let firstTokenAt = null;
|
|
96
|
+
let text = '';
|
|
97
|
+
let finishReason = 'stop';
|
|
98
|
+
let usage = null;
|
|
99
|
+
let chunkCount = 0;
|
|
100
|
+
let probed = 0;
|
|
101
|
+
let pending = [];
|
|
102
|
+
let completed = false;
|
|
103
|
+
let reader;
|
|
104
|
+
try {
|
|
105
|
+
controller.signal.throwIfAborted();
|
|
106
|
+
const response = await fetchImpl(`${llamaUrl.replace(/\/+$/, '')}/v1/chat/completions`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
model: job.model || 'local',
|
|
111
|
+
messages: job.messages,
|
|
112
|
+
stream: true,
|
|
113
|
+
stream_options: { include_usage: true },
|
|
114
|
+
temperature: job.temperature,
|
|
115
|
+
max_tokens: job.maxTokens,
|
|
116
|
+
logprobs: true,
|
|
117
|
+
top_logprobs: TOP_ALTERNATIVES,
|
|
118
|
+
...(job.seed != null ? { seed: job.seed } : {}),
|
|
119
|
+
}),
|
|
120
|
+
signal: controller.signal,
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok || !response.body) {
|
|
123
|
+
const body = await response.text().catch(() => '');
|
|
124
|
+
throw new Error(`llama-server returned ${response.status}${body ? `: ${body.slice(0, 300)}` : ''}`);
|
|
125
|
+
}
|
|
126
|
+
const parser = new SseParser();
|
|
127
|
+
reader = response.body.getReader();
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
const consume = (event) => {
|
|
130
|
+
if (completed)
|
|
131
|
+
return;
|
|
132
|
+
if (event === '[DONE]') {
|
|
133
|
+
completed = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let json;
|
|
137
|
+
try {
|
|
138
|
+
json = JSON.parse(event);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error('Malformed JSON in llama-server stream');
|
|
142
|
+
}
|
|
143
|
+
if (!json || typeof json !== 'object' || Array.isArray(json))
|
|
144
|
+
throw new Error('Invalid llama-server stream event');
|
|
145
|
+
if (json.error) {
|
|
146
|
+
const error = json.error;
|
|
147
|
+
throw new Error(typeof error === 'string' ? error : error.message || 'llama-server stream error');
|
|
148
|
+
}
|
|
149
|
+
const eventUsage = json.usage;
|
|
150
|
+
if (eventUsage) {
|
|
151
|
+
const counts = [eventUsage.prompt_tokens, eventUsage.completion_tokens, eventUsage.total_tokens];
|
|
152
|
+
if (!counts.every((n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))
|
|
153
|
+
throw new Error('Invalid llama-server token usage');
|
|
154
|
+
usage = {
|
|
155
|
+
prompt_tokens: Number(eventUsage.prompt_tokens || 0),
|
|
156
|
+
completion_tokens: Number(eventUsage.completion_tokens || 0),
|
|
157
|
+
total_tokens: Number(eventUsage.total_tokens || 0),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const choices = Array.isArray(json.choices) ? json.choices : [];
|
|
161
|
+
for (const choice of choices) {
|
|
162
|
+
if (!choice || typeof choice !== 'object')
|
|
163
|
+
throw new Error('Invalid llama-server choice');
|
|
164
|
+
const delta = choice.delta && typeof choice.delta === 'object' ? choice.delta : {};
|
|
165
|
+
const lp = choice.logprobs && typeof choice.logprobs === 'object' ? choice.logprobs : null;
|
|
166
|
+
for (const entry of Array.isArray(lp?.content) ? lp.content : []) {
|
|
167
|
+
if (probed >= PROBED_POSITIONS)
|
|
168
|
+
break;
|
|
169
|
+
const position = toPosition(entry);
|
|
170
|
+
if (!position)
|
|
171
|
+
continue;
|
|
172
|
+
pending.push(position);
|
|
173
|
+
probed++;
|
|
174
|
+
}
|
|
175
|
+
const piece = deltaContent(delta.content);
|
|
176
|
+
if (piece) {
|
|
177
|
+
if (text.length + piece.length > 16 * 1024 * 1024)
|
|
178
|
+
throw new Error('llama-server output exceeds limit');
|
|
179
|
+
if (firstTokenAt == null)
|
|
180
|
+
firstTokenAt = Date.now();
|
|
181
|
+
text += piece;
|
|
182
|
+
chunkCount++;
|
|
183
|
+
options.onChunk?.(piece, pending);
|
|
184
|
+
pending = [];
|
|
185
|
+
}
|
|
186
|
+
if (typeof choice.finish_reason === 'string' && choice.finish_reason)
|
|
187
|
+
finishReason = choice.finish_reason;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
while (!completed) {
|
|
191
|
+
controller.signal.throwIfAborted();
|
|
192
|
+
const { done, value } = await reader.read();
|
|
193
|
+
if (done) {
|
|
194
|
+
for (const event of parser.push(decoder.decode()))
|
|
195
|
+
consume(event);
|
|
196
|
+
for (const event of parser.flush())
|
|
197
|
+
consume(event);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
for (const event of parser.push(decoder.decode(value, { stream: true })))
|
|
201
|
+
consume(event);
|
|
202
|
+
}
|
|
203
|
+
controller.signal.throwIfAborted();
|
|
204
|
+
if (!completed)
|
|
205
|
+
throw new Error('llama-server stream ended before [DONE]');
|
|
206
|
+
const elapsed = Math.max(1, Date.now() - started);
|
|
207
|
+
// Events update usage inside consume(), which TypeScript cannot track here.
|
|
208
|
+
const finalUsage = usage;
|
|
209
|
+
const completion = finalUsage?.completion_tokens ?? chunkCount;
|
|
210
|
+
return {
|
|
211
|
+
text,
|
|
212
|
+
finishReason,
|
|
213
|
+
promptTokens: finalUsage?.prompt_tokens ?? 0,
|
|
214
|
+
completionTokens: completion,
|
|
215
|
+
usage: finalUsage,
|
|
216
|
+
ttftMs: firstTokenAt ? firstTokenAt - started : null,
|
|
217
|
+
tokPerSec: completion / (elapsed / 1000),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (reader) {
|
|
222
|
+
await reader.cancel().catch(() => undefined);
|
|
223
|
+
reader.releaseLock();
|
|
224
|
+
}
|
|
225
|
+
clearTimeout(timeout);
|
|
226
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export function simulateReply(prompt, model) {
|
|
230
|
+
const q = prompt.replace(/\s+/g, ' ').trim();
|
|
231
|
+
const short = q.length > 180 ? `${q.slice(0, 180)}.` : q;
|
|
232
|
+
if (!q)
|
|
233
|
+
return 'Sublera simulator is ready. Send a prompt and every streamed token is metered by the central server.';
|
|
234
|
+
if (/```|function|def |class |sql|python|typescript|javascript|regex/i.test(q)) {
|
|
235
|
+
return [
|
|
236
|
+
'Here is a small, explicit sketch you can replace:',
|
|
237
|
+
'',
|
|
238
|
+
'```ts',
|
|
239
|
+
'export function solve(input: string): string {',
|
|
240
|
+
` // ${short}`,
|
|
241
|
+
' return input.trim();',
|
|
242
|
+
'}',
|
|
243
|
+
'```',
|
|
244
|
+
'',
|
|
245
|
+
`Simulated by Sublera on ${model}. A live GPU would run the real GGUF weights.`,
|
|
246
|
+
].join('\n');
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
`On ${model}, the useful reading of that prompt is: ${short || 'a short request'}.`,
|
|
250
|
+
'This node is in simulation mode: the stream, the meter, and the wallet are real, while the words are a stand-in.',
|
|
251
|
+
'Run sublera doctor, install llama-server, and drop --simulation for real GGUF output.',
|
|
252
|
+
].join(' ');
|
|
253
|
+
}
|
|
254
|
+
/** Stand-in probabilities for test nodes: the same shape as llama-server's, so the server's checks run end to end. */
|
|
255
|
+
export function simulatedPosition(token) {
|
|
256
|
+
return { t: token, l: -0.05, top: [[token, -0.05], [' the', -3.2], [' a', -3.9], ['.', -4.4], [',', -4.8]] };
|
|
257
|
+
}
|
|
258
|
+
export async function simulateChat(job, options = {}) {
|
|
259
|
+
const prompt = job.messages.filter((m) => m.role === 'user').map((m) => m.content).join('\n');
|
|
260
|
+
const text = simulateReply(prompt, job.model || 'sublera-simulator');
|
|
261
|
+
const pieces = text.split(/(\s+)/).filter((piece) => piece.length > 0);
|
|
262
|
+
const started = Date.now();
|
|
263
|
+
let sent = '';
|
|
264
|
+
let first = null;
|
|
265
|
+
const delay = Math.max(5, Math.round(1000 / Math.max(5, options.tokPerSec ?? 24)));
|
|
266
|
+
for (const [index, piece] of pieces.entries()) {
|
|
267
|
+
if (options.signal?.aborted)
|
|
268
|
+
break;
|
|
269
|
+
if (first == null)
|
|
270
|
+
first = Date.now();
|
|
271
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
272
|
+
sent += piece;
|
|
273
|
+
options.onChunk?.(piece, index < PROBED_POSITIONS ? [simulatedPosition(piece)] : []);
|
|
274
|
+
}
|
|
275
|
+
const elapsed = Math.max(1, Date.now() - started);
|
|
276
|
+
return {
|
|
277
|
+
text: sent,
|
|
278
|
+
finishReason: 'stop',
|
|
279
|
+
promptTokens: Math.max(1, Math.ceil(prompt.length / 4)),
|
|
280
|
+
completionTokens: pieces.length,
|
|
281
|
+
usage: { prompt_tokens: Math.max(1, Math.ceil(prompt.length / 4)), completion_tokens: pieces.length, total_tokens: pieces.length + Math.ceil(prompt.length / 4) },
|
|
282
|
+
ttftMs: first ? first - started : null,
|
|
283
|
+
tokPerSec: pieces.length / (elapsed / 1000),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=streamProxy.js.map
|