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,282 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { createWriteStream } from 'node:fs';
|
|
4
|
+
import { mkdir } from 'node:fs/promises';
|
|
5
|
+
import { createServer } from 'node:net';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { logsDir } from '../config.js';
|
|
8
|
+
import { killTree, sleep } from '../util/exec.js';
|
|
9
|
+
export function buildLlamaArgs(options) {
|
|
10
|
+
const slots = Math.max(1, Math.round(options.slots ?? 1));
|
|
11
|
+
// llama-server treats --ctx-size as the total KV cache and divides it across
|
|
12
|
+
// parallel slots. Sublera advertises the per-slot context, so allocate slots * ctx.
|
|
13
|
+
const totalContext = Math.max(256, Math.round(options.context)) * slots;
|
|
14
|
+
const args = [
|
|
15
|
+
'-m', options.modelPath,
|
|
16
|
+
'--host', options.host || '127.0.0.1',
|
|
17
|
+
'--port', String(options.port || 0),
|
|
18
|
+
'--n-gpu-layers', String(options.gpuLayers ?? 999),
|
|
19
|
+
'--ctx-size', String(totalContext),
|
|
20
|
+
'--parallel', String(slots),
|
|
21
|
+
'--cont-batching',
|
|
22
|
+
'--log-disable',
|
|
23
|
+
];
|
|
24
|
+
if (options.extraArgs?.length)
|
|
25
|
+
args.push(...options.extraArgs);
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* llama-server reads its own settings from the environment: LLAMA_ARG_* (any flag, such as a
|
|
30
|
+
* chat template, a KV-cache type, or sampling defaults), LLAMA_LOG_* and LLAMA_API_KEY. A host
|
|
31
|
+
* who also uses llama.cpp privately may have these set. They must not reach the engine: buyers
|
|
32
|
+
* were promised the locked flags, the server's model checks expect them, an API key would lock
|
|
33
|
+
* the agent out of its own engine, and a log setting could write prompts to disk.
|
|
34
|
+
*/
|
|
35
|
+
export function engineEnv(base, extra = {}) {
|
|
36
|
+
const env = {};
|
|
37
|
+
const dropped = [];
|
|
38
|
+
// Overrides are no more trusted than the inherited environment. Filter after merging so
|
|
39
|
+
// an explicit env option cannot reintroduce authentication, logging, or generation flags.
|
|
40
|
+
for (const [name, value] of Object.entries({ ...base, ...extra })) {
|
|
41
|
+
if (/^(LLAMA_ARG_|LLAMA_LOG_)/i.test(name) || /^LLAMA_API_KEY$/i.test(name))
|
|
42
|
+
dropped.push(name);
|
|
43
|
+
else
|
|
44
|
+
env[name] = value;
|
|
45
|
+
}
|
|
46
|
+
return { env, dropped: dropped.sort() };
|
|
47
|
+
}
|
|
48
|
+
export async function findFreePort(host = '127.0.0.1') {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const server = createServer();
|
|
51
|
+
server.unref();
|
|
52
|
+
server.on('error', reject);
|
|
53
|
+
server.listen(0, host, () => {
|
|
54
|
+
const address = server.address();
|
|
55
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
56
|
+
server.close(() => port ? resolve(port) : reject(new Error('Could not allocate a local port')));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export class LlamaRunner extends EventEmitter {
|
|
61
|
+
options;
|
|
62
|
+
logger;
|
|
63
|
+
child = null;
|
|
64
|
+
logStream = null;
|
|
65
|
+
_status = 'stopped';
|
|
66
|
+
_port = 0;
|
|
67
|
+
_url = '';
|
|
68
|
+
_props = null;
|
|
69
|
+
_pid = null;
|
|
70
|
+
stopping = false;
|
|
71
|
+
restartTimes = [];
|
|
72
|
+
startPromise = null;
|
|
73
|
+
restartTimer = null;
|
|
74
|
+
_restarts = 0;
|
|
75
|
+
failuresSinceReady = 0;
|
|
76
|
+
constructor(options) {
|
|
77
|
+
super();
|
|
78
|
+
this.options = options;
|
|
79
|
+
this.logger = options.logger;
|
|
80
|
+
}
|
|
81
|
+
get status() { return this._status; }
|
|
82
|
+
get port() { return this._port; }
|
|
83
|
+
get url() { return this._url; }
|
|
84
|
+
get pid() { return this._pid; }
|
|
85
|
+
get props() { return this._props; }
|
|
86
|
+
get restarts() { return this._restarts; }
|
|
87
|
+
setStatus(status) {
|
|
88
|
+
if (this._status === status)
|
|
89
|
+
return;
|
|
90
|
+
this._status = status;
|
|
91
|
+
this.emit('status', status);
|
|
92
|
+
}
|
|
93
|
+
async start() {
|
|
94
|
+
if (this._status === 'ready' && this._props)
|
|
95
|
+
return this._props;
|
|
96
|
+
if (this.startPromise)
|
|
97
|
+
return this.startPromise;
|
|
98
|
+
this.startPromise = this.startInternal();
|
|
99
|
+
try {
|
|
100
|
+
return await this.startPromise;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
this.startPromise = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async startInternal() {
|
|
107
|
+
this.stopping = false;
|
|
108
|
+
this.setStatus(this._restarts > 0 ? 'restarting' : 'starting');
|
|
109
|
+
const port = this.options.port ?? await findFreePort(this.options.host || '127.0.0.1');
|
|
110
|
+
this._port = port;
|
|
111
|
+
this._url = `http://${this.options.host || '127.0.0.1'}:${port}`;
|
|
112
|
+
const args = [
|
|
113
|
+
...(this.options.baseArgs || []),
|
|
114
|
+
...buildLlamaArgs({ ...this.options, port }),
|
|
115
|
+
];
|
|
116
|
+
this.logger?.info('Starting llama-server', { port, context: this.options.context, slots: this.options.slots ?? 1 });
|
|
117
|
+
await mkdir(logsDir(), { recursive: true }).catch(() => undefined);
|
|
118
|
+
if (this.stopping)
|
|
119
|
+
throw new Error('llama-server start was cancelled');
|
|
120
|
+
const logFile = this.options.logFile ?? join(logsDir(), 'llama-server.log');
|
|
121
|
+
this.logStream?.end();
|
|
122
|
+
this.logStream = createWriteStream(logFile, { flags: 'a' });
|
|
123
|
+
const { env, dropped } = engineEnv(process.env, this.options.env);
|
|
124
|
+
if (dropped.length && this._restarts === 0) {
|
|
125
|
+
this.logger?.warn(`Ignoring your llama.cpp settings for this engine: ${dropped.join(', ')}. Sublera runs llama-server with fixed flags.`);
|
|
126
|
+
}
|
|
127
|
+
const child = spawn(this.options.binaryPath, args, {
|
|
128
|
+
env,
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
|
+
});
|
|
132
|
+
this.child = child;
|
|
133
|
+
this._pid = child.pid ?? null;
|
|
134
|
+
const handleLine = (line) => {
|
|
135
|
+
this.options.onLog?.(line);
|
|
136
|
+
this.logStream?.write(`${new Date().toISOString()} ${line}\n`);
|
|
137
|
+
};
|
|
138
|
+
child.stdout?.on('data', (chunk) => {
|
|
139
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/))
|
|
140
|
+
if (line.trim())
|
|
141
|
+
handleLine(line);
|
|
142
|
+
});
|
|
143
|
+
child.stderr?.on('data', (chunk) => {
|
|
144
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/))
|
|
145
|
+
if (line.trim())
|
|
146
|
+
handleLine(line);
|
|
147
|
+
});
|
|
148
|
+
child.once('error', (error) => {
|
|
149
|
+
handleLine(`spawn error: ${error.message}`);
|
|
150
|
+
this.setStatus('failed');
|
|
151
|
+
this.emit('fatal', error);
|
|
152
|
+
});
|
|
153
|
+
let ready = false;
|
|
154
|
+
child.once('exit', (code, signal) => {
|
|
155
|
+
const wasStopping = this.stopping;
|
|
156
|
+
this.child = null;
|
|
157
|
+
this._pid = null;
|
|
158
|
+
this._props = null;
|
|
159
|
+
if (wasStopping) {
|
|
160
|
+
this.setStatus('stopped');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
handleLine(`llama-server exited (${code ?? signal ?? 'unknown'})`);
|
|
164
|
+
if (!ready)
|
|
165
|
+
return;
|
|
166
|
+
this.emit('crash', { code, signal });
|
|
167
|
+
this.scheduleRestart();
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
const props = await this.waitForHealth(child);
|
|
171
|
+
this._props = props;
|
|
172
|
+
const seen = Number(props.n_ctx || props.default_generation_settings?.n_ctx || 0);
|
|
173
|
+
if (seen > 0 && seen < this.options.context) {
|
|
174
|
+
throw new Error(`llama-server loaded context ${seen}, below the locked ${this.options.context}`);
|
|
175
|
+
}
|
|
176
|
+
ready = true;
|
|
177
|
+
this.failuresSinceReady = 0;
|
|
178
|
+
this.setStatus('ready');
|
|
179
|
+
this.emit('ready', props);
|
|
180
|
+
return props;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.setStatus(this.stopping ? 'stopped' : 'failed');
|
|
184
|
+
child.removeAllListeners('exit');
|
|
185
|
+
this.child = null;
|
|
186
|
+
this._pid = null;
|
|
187
|
+
await killTree(child);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async waitForHealth(child) {
|
|
192
|
+
const timeoutMs = this.options.healthTimeoutMs ?? 180_000;
|
|
193
|
+
const started = Date.now();
|
|
194
|
+
let lastError = '';
|
|
195
|
+
while (Date.now() - started < timeoutMs) {
|
|
196
|
+
if (this.stopping)
|
|
197
|
+
throw new Error('llama-server start was cancelled');
|
|
198
|
+
if (child.exitCode != null || !this.child)
|
|
199
|
+
throw new Error(`llama-server exited during startup (${child.exitCode ?? 'signal'})`);
|
|
200
|
+
try {
|
|
201
|
+
const health = await fetch(`${this._url}/health`, { signal: AbortSignal.timeout(2_000) });
|
|
202
|
+
if (health.ok) {
|
|
203
|
+
const props = await fetch(`${this._url}/props`, { signal: AbortSignal.timeout(3_000) }).catch(() => null);
|
|
204
|
+
if (props?.ok)
|
|
205
|
+
return await props.json();
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
211
|
+
}
|
|
212
|
+
await sleep(500);
|
|
213
|
+
}
|
|
214
|
+
throw new Error(`llama-server did not become healthy within ${Math.round(timeoutMs / 1000)}s${lastError ? ` (${lastError})` : ''}`);
|
|
215
|
+
}
|
|
216
|
+
scheduleRestart() {
|
|
217
|
+
if (this.stopping)
|
|
218
|
+
return;
|
|
219
|
+
const restart = this.options.restart;
|
|
220
|
+
if (restart === false) {
|
|
221
|
+
this.setStatus('failed');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const maxRestarts = restart?.maxRestarts ?? 5;
|
|
225
|
+
const windowMs = restart?.windowMs ?? 120_000;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
this.restartTimes = this.restartTimes.filter((t) => now - t < windowMs);
|
|
228
|
+
const reason = this.failuresSinceReady >= maxRestarts
|
|
229
|
+
? `llama-server did not become ready after ${this.failuresSinceReady} restarts`
|
|
230
|
+
: this.restartTimes.length >= maxRestarts
|
|
231
|
+
? `llama-server crashed ${this.restartTimes.length} times in ${Math.round(windowMs / 1000)}s`
|
|
232
|
+
: null;
|
|
233
|
+
if (reason) {
|
|
234
|
+
this.setStatus('failed');
|
|
235
|
+
this.emit('fatal', new Error(reason));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.restartTimes.push(now);
|
|
239
|
+
this.failuresSinceReady++;
|
|
240
|
+
this._restarts++;
|
|
241
|
+
this.setStatus('restarting');
|
|
242
|
+
const delay = restart?.delayMs ?? 500;
|
|
243
|
+
if (this.restartTimer)
|
|
244
|
+
clearTimeout(this.restartTimer);
|
|
245
|
+
this.restartTimer = setTimeout(() => {
|
|
246
|
+
this.restartTimer = null;
|
|
247
|
+
if (this.stopping)
|
|
248
|
+
return;
|
|
249
|
+
void this.start().catch((error) => {
|
|
250
|
+
this.logger?.error('llama-server restart failed', { error: error instanceof Error ? error.message : String(error) });
|
|
251
|
+
this.scheduleRestart();
|
|
252
|
+
});
|
|
253
|
+
}, delay);
|
|
254
|
+
}
|
|
255
|
+
async stop() {
|
|
256
|
+
this.stopping = true;
|
|
257
|
+
if (this.restartTimer) {
|
|
258
|
+
clearTimeout(this.restartTimer);
|
|
259
|
+
this.restartTimer = null;
|
|
260
|
+
}
|
|
261
|
+
const child = this.child;
|
|
262
|
+
this.child = null;
|
|
263
|
+
this._props = null;
|
|
264
|
+
if (child)
|
|
265
|
+
await killTree(child);
|
|
266
|
+
this.logStream?.end();
|
|
267
|
+
this.logStream = null;
|
|
268
|
+
this.setStatus('stopped');
|
|
269
|
+
}
|
|
270
|
+
async isHealthy() {
|
|
271
|
+
if (!this._url || this._status !== 'ready')
|
|
272
|
+
return false;
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`${this._url}/health`, { signal: AbortSignal.timeout(2_000) });
|
|
275
|
+
return res.ok;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=llamaRunner.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type GgufInfo } from './gguf.js';
|
|
2
|
+
import type { CachedModelInfo } from '../types.js';
|
|
3
|
+
export type ModelProgress = {
|
|
4
|
+
phase: 'download' | 'verify' | 'header' | 'cache';
|
|
5
|
+
received: number;
|
|
6
|
+
total: number | null;
|
|
7
|
+
percent: number | null;
|
|
8
|
+
speedBps: number | null;
|
|
9
|
+
etaSeconds: number | null;
|
|
10
|
+
note: string;
|
|
11
|
+
};
|
|
12
|
+
export type LoadedModel = {
|
|
13
|
+
path: string;
|
|
14
|
+
file: string;
|
|
15
|
+
url: string;
|
|
16
|
+
sha256: string;
|
|
17
|
+
size: number;
|
|
18
|
+
info: GgufInfo;
|
|
19
|
+
paramB: number | null;
|
|
20
|
+
quant: string | null;
|
|
21
|
+
layers: number | null;
|
|
22
|
+
contextTrain: number | null;
|
|
23
|
+
architecture: string | null;
|
|
24
|
+
needsMb: number | null;
|
|
25
|
+
/** A supplied expected SHA-256 matched measured file bytes; a computed digest alone is not verification. */
|
|
26
|
+
verified: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type DownloadModelOptions = {
|
|
29
|
+
url: string;
|
|
30
|
+
expectedSha256?: string | null;
|
|
31
|
+
expectedSize?: number | null;
|
|
32
|
+
expectedLayers?: number | null;
|
|
33
|
+
context?: number;
|
|
34
|
+
cacheDir?: string;
|
|
35
|
+
onProgress?: (progress: ModelProgress) => void;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
showProgress?: boolean;
|
|
39
|
+
stream?: NodeJS.WriteStream;
|
|
40
|
+
slots?: number;
|
|
41
|
+
/** Unit tests only: allow http(s) downloads from a loopback address. */
|
|
42
|
+
allowLocalhost?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Retained for caller compatibility. Cached files are always hashed before reporting their
|
|
45
|
+
* SHA-256, unless this process already hashed the same file identity and change timestamps.
|
|
46
|
+
*/
|
|
47
|
+
verifyCached?: boolean;
|
|
48
|
+
};
|
|
49
|
+
/** Unit tests: behave like a new agent process that has hashed nothing yet. */
|
|
50
|
+
export declare function forgetHashedModels(): void;
|
|
51
|
+
export declare function modelPathFor(url: string, cacheDir?: string): string;
|
|
52
|
+
/** Cache inventory hints, not proof of a hash check. downloadModel revalidates bytes before use. */
|
|
53
|
+
export declare function listCachedModels(cacheDir?: string): Promise<CachedModelInfo[]>;
|
|
54
|
+
/** Serialize downloads so concurrent jobs in this process cannot spend the same disk budget. */
|
|
55
|
+
export declare function downloadModel(options: DownloadModelOptions): Promise<LoadedModel>;
|
|
56
|
+
export declare function verifyModelFile(path: string, expected?: {
|
|
57
|
+
sha256?: string | null;
|
|
58
|
+
layers?: number | null;
|
|
59
|
+
context?: number;
|
|
60
|
+
slots?: number;
|
|
61
|
+
}): Promise<LoadedModel>;
|
|
62
|
+
export declare function cachedModelName(url: string): string;
|
|
63
|
+
export declare function openModelHeader(path: string, bytes?: number): Promise<GgufInfo>;
|