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,54 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import type { HeartbeatPayload, HelloPayload } from '../types.js';
|
|
4
|
+
import type { Logger } from '../logger.js';
|
|
5
|
+
export type TunnelState = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'auth-failed' | 'outdated';
|
|
6
|
+
export type TunnelOptions = {
|
|
7
|
+
server: string;
|
|
8
|
+
token: string;
|
|
9
|
+
wsPath?: string;
|
|
10
|
+
hello: () => HelloPayload;
|
|
11
|
+
heartbeat?: () => HeartbeatPayload | null;
|
|
12
|
+
logger?: Logger;
|
|
13
|
+
reconnectDelaysMs?: number[];
|
|
14
|
+
heartbeatMs?: number;
|
|
15
|
+
pingIntervalMs?: number;
|
|
16
|
+
staleAfterMs?: number;
|
|
17
|
+
autoReconnect?: boolean;
|
|
18
|
+
authMode?: 'header' | 'query';
|
|
19
|
+
wsFactory?: (url: string) => WebSocket;
|
|
20
|
+
};
|
|
21
|
+
export declare class TunnelClient extends EventEmitter {
|
|
22
|
+
private readonly options;
|
|
23
|
+
private readonly logger?;
|
|
24
|
+
private readonly delays;
|
|
25
|
+
private socket;
|
|
26
|
+
private _state;
|
|
27
|
+
private attempts;
|
|
28
|
+
private reconnectTimer;
|
|
29
|
+
private heartbeatTimer;
|
|
30
|
+
private pingTimer;
|
|
31
|
+
private heartbeatMs;
|
|
32
|
+
private manuallyStopped;
|
|
33
|
+
private authMode;
|
|
34
|
+
private triedQueryFallback;
|
|
35
|
+
private welcomeWaiters;
|
|
36
|
+
private _username;
|
|
37
|
+
private _lastMessageAt;
|
|
38
|
+
constructor(options: TunnelOptions);
|
|
39
|
+
get state(): TunnelState;
|
|
40
|
+
get connected(): boolean;
|
|
41
|
+
get username(): string | null;
|
|
42
|
+
get lastMessageAt(): number;
|
|
43
|
+
private setState;
|
|
44
|
+
start(): void;
|
|
45
|
+
/** Resolves once the server has sent its welcome frame. */
|
|
46
|
+
waitForWelcome(timeoutMs?: number): Promise<void>;
|
|
47
|
+
private connect;
|
|
48
|
+
private startTimers;
|
|
49
|
+
private stopTimers;
|
|
50
|
+
private scheduleReconnect;
|
|
51
|
+
send(message: Record<string, unknown>): boolean;
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
private rejectWelcomeWaiters;
|
|
54
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { RECONNECT_DELAYS_MS, HEARTBEAT_MS, UPGRADE_REQUIRED_CLOSE, USER_AGENT, outdatedMessage } from '../version.js';
|
|
4
|
+
import { wsUrl as buildWsUrl } from '../config.js';
|
|
5
|
+
import { websocketAgentFor } from './proxy.js';
|
|
6
|
+
export class TunnelClient extends EventEmitter {
|
|
7
|
+
options;
|
|
8
|
+
logger;
|
|
9
|
+
delays;
|
|
10
|
+
socket = null;
|
|
11
|
+
_state = 'closed';
|
|
12
|
+
attempts = 0;
|
|
13
|
+
reconnectTimer = null;
|
|
14
|
+
heartbeatTimer = null;
|
|
15
|
+
pingTimer = null;
|
|
16
|
+
heartbeatMs;
|
|
17
|
+
manuallyStopped = false;
|
|
18
|
+
authMode;
|
|
19
|
+
triedQueryFallback = false;
|
|
20
|
+
welcomeWaiters = [];
|
|
21
|
+
_username = null;
|
|
22
|
+
_lastMessageAt = 0;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
super();
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.logger = options.logger;
|
|
27
|
+
this.delays = options.reconnectDelaysMs || RECONNECT_DELAYS_MS;
|
|
28
|
+
this.heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS;
|
|
29
|
+
this.authMode = options.authMode ?? 'header';
|
|
30
|
+
}
|
|
31
|
+
get state() { return this._state; }
|
|
32
|
+
get connected() { return this._state === 'open' && this.socket?.readyState === WebSocket.OPEN; }
|
|
33
|
+
get username() { return this._username; }
|
|
34
|
+
get lastMessageAt() { return this._lastMessageAt; }
|
|
35
|
+
setState(state) {
|
|
36
|
+
if (this._state === state)
|
|
37
|
+
return;
|
|
38
|
+
this._state = state;
|
|
39
|
+
this.emit('state', state);
|
|
40
|
+
}
|
|
41
|
+
start() {
|
|
42
|
+
this.manuallyStopped = false;
|
|
43
|
+
if (this._state === 'open' || this._state === 'connecting')
|
|
44
|
+
return;
|
|
45
|
+
this.connect();
|
|
46
|
+
}
|
|
47
|
+
/** Resolves once the server has sent its welcome frame. */
|
|
48
|
+
waitForWelcome(timeoutMs = 10_000) {
|
|
49
|
+
if (this._state === 'open' && this._username != null)
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
this.welcomeWaiters = this.welcomeWaiters.filter((w) => w.timer !== timer);
|
|
54
|
+
reject(new Error('Timed out waiting for the host tunnel welcome'));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
this.welcomeWaiters.push({ resolve, reject, timer });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
connect() {
|
|
60
|
+
if (this.manuallyStopped)
|
|
61
|
+
return;
|
|
62
|
+
this.setState(this.attempts === 0 ? 'connecting' : 'reconnecting');
|
|
63
|
+
const useQuery = this.authMode === 'query';
|
|
64
|
+
const url = buildWsUrl(this.options.server, useQuery ? this.options.token : '', this.options.wsPath, useQuery ? 'query' : 'header');
|
|
65
|
+
this.logger?.debug('Opening tunnel', { url: url.replace(/token=[^&]+/, 'token=***'), auth: this.authMode });
|
|
66
|
+
let socket;
|
|
67
|
+
try {
|
|
68
|
+
const headers = { 'user-agent': USER_AGENT };
|
|
69
|
+
if (!useQuery)
|
|
70
|
+
headers.authorization = `Bearer ${this.options.token}`;
|
|
71
|
+
socket = this.options.wsFactory ? this.options.wsFactory(url) : new WebSocket(url, {
|
|
72
|
+
headers,
|
|
73
|
+
handshakeTimeout: 15_000,
|
|
74
|
+
// A corporate HTTPS_PROXY is used for the tunnel too; undefined means a direct connection.
|
|
75
|
+
agent: websocketAgentFor(url),
|
|
76
|
+
// Server frames are small JSON messages; refuse anything absurd instead of buffering it.
|
|
77
|
+
maxPayload: 16 * 1024 * 1024,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
this.logger?.warn('Tunnel connection failed to start', { error: error instanceof Error ? error.message : String(error) });
|
|
82
|
+
this.scheduleReconnect();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.socket = socket;
|
|
86
|
+
let opened = false;
|
|
87
|
+
let handshakeStatus = 0;
|
|
88
|
+
socket.on('open', () => {
|
|
89
|
+
opened = true;
|
|
90
|
+
this.attempts = 0;
|
|
91
|
+
this._lastMessageAt = Date.now();
|
|
92
|
+
this.setState('open');
|
|
93
|
+
this.emit('open');
|
|
94
|
+
this.send(this.options.hello());
|
|
95
|
+
this.startTimers();
|
|
96
|
+
this.logger?.info('Tunnel connected');
|
|
97
|
+
});
|
|
98
|
+
socket.on('message', (raw) => {
|
|
99
|
+
this._lastMessageAt = Date.now();
|
|
100
|
+
let message;
|
|
101
|
+
try {
|
|
102
|
+
message = JSON.parse(raw.toString());
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!message || typeof message !== 'object')
|
|
108
|
+
return;
|
|
109
|
+
if (message.type === 'welcome') {
|
|
110
|
+
this._username = typeof message.username === 'string' ? message.username : this._username;
|
|
111
|
+
for (const waiter of this.welcomeWaiters) {
|
|
112
|
+
clearTimeout(waiter.timer);
|
|
113
|
+
waiter.resolve();
|
|
114
|
+
}
|
|
115
|
+
this.welcomeWaiters = [];
|
|
116
|
+
}
|
|
117
|
+
this.emit('message', message);
|
|
118
|
+
});
|
|
119
|
+
socket.on('pong', () => { this._lastMessageAt = Date.now(); });
|
|
120
|
+
socket.on('error', (error) => {
|
|
121
|
+
const refused = /Unexpected server response: (\d+)/.exec(error.message);
|
|
122
|
+
if (refused)
|
|
123
|
+
handshakeStatus = Number(refused[1]);
|
|
124
|
+
this.logger?.debug('Tunnel error', { error: error.message });
|
|
125
|
+
});
|
|
126
|
+
socket.on('close', (code, reason) => {
|
|
127
|
+
this.stopTimers();
|
|
128
|
+
this.socket = null;
|
|
129
|
+
if (this.manuallyStopped) {
|
|
130
|
+
this.setState('closed');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const text = reason?.toString() || '';
|
|
134
|
+
if (code === UPGRADE_REQUIRED_CLOSE || handshakeStatus === 426) {
|
|
135
|
+
this.setState('outdated');
|
|
136
|
+
const error = new Error(outdatedMessage(/\d+\.\d+\.\d+/.exec(text)?.[0]));
|
|
137
|
+
this.rejectWelcomeWaiters(error);
|
|
138
|
+
this.emit('outdated', error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const authClose = code === 4001 || code === 4003 || handshakeStatus === 401 || handshakeStatus === 403 || /token|auth/i.test(text);
|
|
142
|
+
if (authClose) {
|
|
143
|
+
if (this.authMode === 'header' && !this.triedQueryFallback) {
|
|
144
|
+
this.authMode = 'query';
|
|
145
|
+
this.triedQueryFallback = true;
|
|
146
|
+
this.logger?.debug('Header auth was rejected; retrying with the query-token fallback');
|
|
147
|
+
this.setState('reconnecting');
|
|
148
|
+
this.scheduleReconnect();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.setState('auth-failed');
|
|
152
|
+
const error = new Error(`Host key was rejected by the server (${handshakeStatus ? `HTTP ${handshakeStatus}` : code}${text ? `: ${text}` : ''})`);
|
|
153
|
+
this.rejectWelcomeWaiters(error);
|
|
154
|
+
this.emit('auth-failed', error);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.logger?.warn('Tunnel closed; reconnecting', { code, reason: text || undefined });
|
|
158
|
+
this.setState('reconnecting');
|
|
159
|
+
this.emit('disconnected', { code, reason: text, opened });
|
|
160
|
+
this.scheduleReconnect();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
startTimers() {
|
|
164
|
+
this.stopTimers();
|
|
165
|
+
this.heartbeatTimer = setInterval(() => {
|
|
166
|
+
if (!this.connected)
|
|
167
|
+
return;
|
|
168
|
+
const heartbeat = this.options.heartbeat?.();
|
|
169
|
+
if (heartbeat)
|
|
170
|
+
this.send(heartbeat);
|
|
171
|
+
}, this.heartbeatMs);
|
|
172
|
+
this.pingTimer = setInterval(() => {
|
|
173
|
+
if (!this.connected || !this.socket)
|
|
174
|
+
return;
|
|
175
|
+
const silentMs = Date.now() - this._lastMessageAt;
|
|
176
|
+
if (silentMs > (this.options.staleAfterMs ?? Math.max(45_000, this.heartbeatMs * 5))) {
|
|
177
|
+
// A half-open TCP connection never emits 'close' on its own; terminate it so the close handler reconnects.
|
|
178
|
+
this.logger?.warn('Tunnel stopped answering pings; reconnecting', { silentMs });
|
|
179
|
+
this.socket.terminate();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
this.socket.ping();
|
|
184
|
+
}
|
|
185
|
+
catch { /* closing */ }
|
|
186
|
+
}, this.options.pingIntervalMs ?? Math.max(10_000, this.heartbeatMs * 2));
|
|
187
|
+
}
|
|
188
|
+
stopTimers() {
|
|
189
|
+
if (this.heartbeatTimer)
|
|
190
|
+
clearInterval(this.heartbeatTimer);
|
|
191
|
+
if (this.pingTimer)
|
|
192
|
+
clearInterval(this.pingTimer);
|
|
193
|
+
this.heartbeatTimer = null;
|
|
194
|
+
this.pingTimer = null;
|
|
195
|
+
}
|
|
196
|
+
scheduleReconnect() {
|
|
197
|
+
if (this.manuallyStopped || this.options.autoReconnect === false)
|
|
198
|
+
return;
|
|
199
|
+
if (this.reconnectTimer)
|
|
200
|
+
return;
|
|
201
|
+
const base = this.delays[Math.min(this.attempts, this.delays.length - 1)] || 30_000;
|
|
202
|
+
this.attempts++;
|
|
203
|
+
const jitter = Math.round(base * 0.15 * (Math.random() * 2 - 1));
|
|
204
|
+
const delay = Math.max(250, base + jitter);
|
|
205
|
+
this.logger?.info(`Reconnecting in ${(delay / 1000).toFixed(1)}s`, { attempt: this.attempts });
|
|
206
|
+
this.reconnectTimer = setTimeout(() => {
|
|
207
|
+
this.reconnectTimer = null;
|
|
208
|
+
this.connect();
|
|
209
|
+
}, delay);
|
|
210
|
+
}
|
|
211
|
+
send(message) {
|
|
212
|
+
if (!this.connected || !this.socket)
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
this.socket.send(JSON.stringify(message));
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async stop() {
|
|
223
|
+
this.manuallyStopped = true;
|
|
224
|
+
this.stopTimers();
|
|
225
|
+
if (this.reconnectTimer)
|
|
226
|
+
clearTimeout(this.reconnectTimer);
|
|
227
|
+
this.reconnectTimer = null;
|
|
228
|
+
const socket = this.socket;
|
|
229
|
+
this.socket = null;
|
|
230
|
+
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
|
231
|
+
await new Promise((resolve) => {
|
|
232
|
+
const timer = setTimeout(() => { try {
|
|
233
|
+
socket.terminate();
|
|
234
|
+
}
|
|
235
|
+
catch { /* */ } resolve(); }, 2_000);
|
|
236
|
+
socket.once('close', () => { clearTimeout(timer); resolve(); });
|
|
237
|
+
try {
|
|
238
|
+
socket.close(1000, 'agent stopped');
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
resolve();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
this.setState('closed');
|
|
247
|
+
this.rejectWelcomeWaiters(new Error('Tunnel stopped'));
|
|
248
|
+
}
|
|
249
|
+
rejectWelcomeWaiters(error) {
|
|
250
|
+
for (const waiter of this.welcomeWaiters) {
|
|
251
|
+
clearTimeout(waiter.timer);
|
|
252
|
+
waiter.reject(error);
|
|
253
|
+
}
|
|
254
|
+
this.welcomeWaiters = [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=tunnelClient.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type LogLevel } from './logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Settings that a fleet or a container can set without command-line flags.
|
|
4
|
+
* Precedence, highest first: command-line flag > SUBLET_* environment variable >
|
|
5
|
+
* agent.json > built-in default. The host key normally lives in config.json
|
|
6
|
+
* (written by `sublet login`) or comes from SUBLET_KEY / --key.
|
|
7
|
+
*/
|
|
8
|
+
export type Settings = {
|
|
9
|
+
server?: string;
|
|
10
|
+
token?: string;
|
|
11
|
+
wsPath?: string;
|
|
12
|
+
context?: number;
|
|
13
|
+
slots?: number;
|
|
14
|
+
price?: number;
|
|
15
|
+
priceIn?: number;
|
|
16
|
+
priceOut?: number;
|
|
17
|
+
priceHour?: number;
|
|
18
|
+
gpu?: string;
|
|
19
|
+
vram?: number;
|
|
20
|
+
tokPerSec?: number;
|
|
21
|
+
noInstall?: boolean;
|
|
22
|
+
noBrowser?: boolean;
|
|
23
|
+
nonInteractive?: boolean;
|
|
24
|
+
drainTimeoutMs?: number;
|
|
25
|
+
requireChecksum?: boolean;
|
|
26
|
+
logLevel?: LogLevel;
|
|
27
|
+
/** A path, or "off" to disable the agent log file. */
|
|
28
|
+
logFile?: string;
|
|
29
|
+
};
|
|
30
|
+
export type SettingSource = 'flag' | 'env' | 'file' | 'default';
|
|
31
|
+
type Kind = 'string' | 'number' | 'integer' | 'boolean' | 'level';
|
|
32
|
+
/** One row per setting: its agent.json key, environment variable, and type. */
|
|
33
|
+
export declare const SETTINGS_SPEC: ReadonlyArray<{
|
|
34
|
+
key: keyof Settings;
|
|
35
|
+
env: string;
|
|
36
|
+
kind: Kind;
|
|
37
|
+
secret?: boolean;
|
|
38
|
+
help: string;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function settingsPath(env?: NodeJS.ProcessEnv): string;
|
|
41
|
+
/** Reads agent.json. A missing file is fine; a broken or misspelled one is an error, never silently ignored. */
|
|
42
|
+
export declare function readSettingsFile(path?: string): Settings;
|
|
43
|
+
export declare function readSettingsEnv(env?: NodeJS.ProcessEnv): Settings;
|
|
44
|
+
export type ResolvedSettings = {
|
|
45
|
+
values: Settings;
|
|
46
|
+
sources: Partial<Record<keyof Settings, SettingSource>>;
|
|
47
|
+
file: string;
|
|
48
|
+
};
|
|
49
|
+
/** Fills every flag the person did not pass from SUBLET_* variables, then agent.json. Returns where each value came from. */
|
|
50
|
+
export declare function applySettings<T extends object>(flags: T, explicit: ReadonlySet<string>, env?: NodeJS.ProcessEnv): ResolvedSettings;
|
|
51
|
+
/** Merges flags over environment over agent.json. */
|
|
52
|
+
export declare function resolveSettings(fromFlags: Settings, env?: NodeJS.ProcessEnv): ResolvedSettings;
|
|
53
|
+
export {};
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { subletHome } from './config.js';
|
|
4
|
+
import { brandEnv } from './util/env.js';
|
|
5
|
+
import { isLogLevel } from './logger.js';
|
|
6
|
+
/** One row per setting: its agent.json key, environment variable, and type. */
|
|
7
|
+
export const SETTINGS_SPEC = [
|
|
8
|
+
{ key: 'server', env: 'SUBLET_SERVER', kind: 'string', help: 'Central server URL' },
|
|
9
|
+
{ key: 'token', env: 'SUBLET_KEY', kind: 'string', secret: true, help: 'Host key (sh_...); prefer sublet login' },
|
|
10
|
+
{ key: 'wsPath', env: 'SUBLET_WS_PATH', kind: 'string', help: 'Tunnel path' },
|
|
11
|
+
{ key: 'context', env: 'SUBLET_CTX', kind: 'integer', help: 'Context window per slot' },
|
|
12
|
+
{ key: 'slots', env: 'SUBLET_SLOTS', kind: 'integer', help: 'Parallel slots for public mode' },
|
|
13
|
+
{ key: 'price', env: 'SUBLET_PRICE', kind: 'number', help: 'Public price per 1M output tokens (USD)' },
|
|
14
|
+
{ key: 'priceIn', env: 'SUBLET_PRICE_IN', kind: 'number', help: 'Public price per 1M input tokens (USD)' },
|
|
15
|
+
{ key: 'priceOut', env: 'SUBLET_PRICE_OUT', kind: 'number', help: 'Public price per 1M output tokens (USD)' },
|
|
16
|
+
{ key: 'priceHour', env: 'SUBLET_PRICE_HOUR', kind: 'number', help: 'Host mode price per hour for the full GPU (USD, 0-50; 0 = no hourly rentals)' },
|
|
17
|
+
{ key: 'gpu', env: 'SUBLET_GPU', kind: 'string', help: 'Correct a wrongly detected GPU name' },
|
|
18
|
+
{ key: 'vram', env: 'SUBLET_VRAM', kind: 'integer', help: 'Correct a wrongly detected VRAM size (MB)' },
|
|
19
|
+
{ key: 'tokPerSec', env: 'SUBLET_TOK_S', kind: 'number', help: 'Advertised tokens/sec (the server measures its own)' },
|
|
20
|
+
{ key: 'noInstall', env: 'SUBLET_NO_INSTALL', kind: 'boolean', help: 'Never download llama-server' },
|
|
21
|
+
{ key: 'noBrowser', env: 'SUBLET_NO_BROWSER', kind: 'boolean', help: 'Never open a browser' },
|
|
22
|
+
{ key: 'nonInteractive', env: 'SUBLET_NON_INTERACTIVE', kind: 'boolean', help: 'Never prompt or begin device login; fail when a key is missing' },
|
|
23
|
+
{ key: 'drainTimeoutMs', env: 'SUBLET_DRAIN_TIMEOUT_MS', kind: 'integer', help: 'SIGTERM inference drain grace period (0–300000 ms; default 30000)' },
|
|
24
|
+
{ key: 'requireChecksum', env: 'SUBLET_REQUIRE_CHECKSUM', kind: 'boolean', help: 'Refuse a llama.cpp release without a published SHA-256' },
|
|
25
|
+
{ key: 'logLevel', env: 'SUBLET_LOG_LEVEL', kind: 'level', help: 'Terminal log level: debug, info, warn, error, silent' },
|
|
26
|
+
{ key: 'logFile', env: 'SUBLET_LOG_FILE', kind: 'string', help: 'Agent log file path, or "off"' },
|
|
27
|
+
];
|
|
28
|
+
export function settingsPath(env = process.env) {
|
|
29
|
+
const explicit = brandEnv(env, 'SUBLET_CONFIG')?.trim();
|
|
30
|
+
return explicit ? resolve(explicit) : join(subletHome(env), 'agent.json');
|
|
31
|
+
}
|
|
32
|
+
function coerce(kind, raw, where) {
|
|
33
|
+
if (kind === 'string') {
|
|
34
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
35
|
+
throw new Error(`${where} must be a non-empty string`);
|
|
36
|
+
return raw.trim();
|
|
37
|
+
}
|
|
38
|
+
if (kind === 'boolean') {
|
|
39
|
+
if (typeof raw === 'boolean')
|
|
40
|
+
return raw;
|
|
41
|
+
const text = String(raw).trim().toLowerCase();
|
|
42
|
+
if (['1', 'true', 'yes', 'on'].includes(text))
|
|
43
|
+
return true;
|
|
44
|
+
if (['0', 'false', 'no', 'off'].includes(text))
|
|
45
|
+
return false;
|
|
46
|
+
throw new Error(`${where} must be true or false, got "${String(raw)}"`);
|
|
47
|
+
}
|
|
48
|
+
if (kind === 'level') {
|
|
49
|
+
const text = String(raw).trim().toLowerCase();
|
|
50
|
+
if (!isLogLevel(text))
|
|
51
|
+
throw new Error(`${where} must be one of debug, info, warn, error, silent; got "${String(raw)}"`);
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
const value = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
|
|
55
|
+
if (!Number.isFinite(value))
|
|
56
|
+
throw new Error(`${where} must be a number, got "${String(raw)}"`);
|
|
57
|
+
if (value < 0)
|
|
58
|
+
throw new Error(`${where} must not be negative`);
|
|
59
|
+
if (kind === 'integer' && !Number.isInteger(value))
|
|
60
|
+
throw new Error(`${where} must be a whole number`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/** Reads agent.json. A missing file is fine; a broken or misspelled one is an error, never silently ignored. */
|
|
64
|
+
export function readSettingsFile(path = settingsPath()) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(path, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code === 'ENOENT')
|
|
71
|
+
return {};
|
|
72
|
+
throw new Error(`Could not read ${path}: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
let data;
|
|
75
|
+
try {
|
|
76
|
+
data = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// JSON.parse errors may quote part of a secret-bearing configuration file.
|
|
80
|
+
throw new Error(`${path} is not valid JSON. Check commas, quotes and braces.`);
|
|
81
|
+
}
|
|
82
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
83
|
+
throw new Error(`${path} must contain a JSON object`);
|
|
84
|
+
if ('version' in data && data.version !== 1)
|
|
85
|
+
throw new Error(`${path}: unsupported settings version; expected 1`);
|
|
86
|
+
const known = new Map(SETTINGS_SPEC.map((row) => [row.key, row]));
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [key, value] of Object.entries(data)) {
|
|
89
|
+
if (key === '$schema' || key === 'version')
|
|
90
|
+
continue;
|
|
91
|
+
const row = known.get(key);
|
|
92
|
+
if (!row)
|
|
93
|
+
throw new Error(`${path}: unknown setting "${key}". Known settings: ${[...known.keys()].join(', ')}`);
|
|
94
|
+
if (value == null)
|
|
95
|
+
continue;
|
|
96
|
+
out[key] = coerce(row.kind, value, `${path}: ${key}`);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
export function readSettingsEnv(env = process.env) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const row of SETTINGS_SPEC) {
|
|
103
|
+
const value = brandEnv(env, row.env);
|
|
104
|
+
if (value == null || value === '')
|
|
105
|
+
continue;
|
|
106
|
+
out[row.key] = coerce(row.kind, value, row.env);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Fills every flag the person did not pass from SUBLET_* variables, then agent.json. Returns where each value came from. */
|
|
111
|
+
export function applySettings(flags, explicit, env = process.env) {
|
|
112
|
+
const target = flags;
|
|
113
|
+
const fromFlags = {};
|
|
114
|
+
for (const row of SETTINGS_SPEC) {
|
|
115
|
+
if (explicit.has(row.key))
|
|
116
|
+
fromFlags[row.key] = target[row.key];
|
|
117
|
+
}
|
|
118
|
+
const resolved = resolveSettings(fromFlags, env);
|
|
119
|
+
for (const row of SETTINGS_SPEC) {
|
|
120
|
+
const value = resolved.values[row.key];
|
|
121
|
+
if (value === undefined || explicit.has(row.key))
|
|
122
|
+
continue;
|
|
123
|
+
// Boolean switches can only be turned on by a setting; flags never default to true.
|
|
124
|
+
if (row.kind === 'boolean' && value !== true)
|
|
125
|
+
continue;
|
|
126
|
+
target[row.key] = value;
|
|
127
|
+
}
|
|
128
|
+
return resolved;
|
|
129
|
+
}
|
|
130
|
+
/** Merges flags over environment over agent.json. */
|
|
131
|
+
export function resolveSettings(fromFlags, env = process.env) {
|
|
132
|
+
const file = settingsPath(env);
|
|
133
|
+
if (brandEnv(env, 'SUBLET_CONFIG')?.trim()) {
|
|
134
|
+
try {
|
|
135
|
+
readFileSync(file, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error(`Cannot read the settings file selected by SUBLET_CONFIG: ${file}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const layers = [['file', readSettingsFile(file)], ['env', readSettingsEnv(env)], ['flag', fromFlags]];
|
|
142
|
+
const values = {};
|
|
143
|
+
const sources = {};
|
|
144
|
+
for (const [source, layer] of layers) {
|
|
145
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
146
|
+
if (value === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
values[key] = value;
|
|
149
|
+
sources[key] = source;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const row of SETTINGS_SPEC) {
|
|
153
|
+
if (values[row.key] !== undefined)
|
|
154
|
+
values[row.key] = coerce(row.kind, values[row.key], row.env);
|
|
155
|
+
}
|
|
156
|
+
for (const key of ['context', 'slots']) {
|
|
157
|
+
const n = values[key];
|
|
158
|
+
if (typeof n === 'number' && !Number.isSafeInteger(n))
|
|
159
|
+
throw new Error(`${key} must be a safe whole number`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof values.context === 'number' && values.context < 256)
|
|
162
|
+
throw new Error('context must be at least 256 tokens');
|
|
163
|
+
if (typeof values.slots === 'number' && (values.slots < 1 || values.slots > 16))
|
|
164
|
+
throw new Error('slots must be between 1 and 16');
|
|
165
|
+
if (typeof values.drainTimeoutMs === 'number' && values.drainTimeoutMs > 300_000)
|
|
166
|
+
throw new Error('drainTimeoutMs must be between 0 and 300000');
|
|
167
|
+
if (typeof values.priceHour === 'number' && values.priceHour > 50)
|
|
168
|
+
throw new Error('priceHour must be between 0 and 50 USD');
|
|
169
|
+
return { values: values, sources, file };
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=settings.js.map
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type PersistedModel = {
|
|
2
|
+
url: string;
|
|
3
|
+
path: string;
|
|
4
|
+
file: string;
|
|
5
|
+
modelId: string;
|
|
6
|
+
label: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
quant: string;
|
|
9
|
+
paramB: number | null;
|
|
10
|
+
layers: number | null;
|
|
11
|
+
contextTrain: number | null;
|
|
12
|
+
sha256: string | null;
|
|
13
|
+
verified: boolean;
|
|
14
|
+
needsMb: number | null;
|
|
15
|
+
size: number;
|
|
16
|
+
architecture: string | null;
|
|
17
|
+
};
|
|
18
|
+
export type PersistedBooking = {
|
|
19
|
+
requestId: string;
|
|
20
|
+
model: PersistedModel;
|
|
21
|
+
context: number;
|
|
22
|
+
slots: number;
|
|
23
|
+
timerStartedAt: number | null;
|
|
24
|
+
timerEndsAt: number | null;
|
|
25
|
+
hours: number | null;
|
|
26
|
+
pricePerHourNanos: number | null;
|
|
27
|
+
minTokS: number | null;
|
|
28
|
+
guaranteedNanos: number | null;
|
|
29
|
+
/** A speed seat that shares the GPU; absent in files written before seats existed. */
|
|
30
|
+
shared?: boolean;
|
|
31
|
+
/** The reserved speed of a seat; null or absent means the full GPU. */
|
|
32
|
+
tokS?: number | null;
|
|
33
|
+
savedAt: number;
|
|
34
|
+
};
|
|
35
|
+
export type PersistedState = {
|
|
36
|
+
version: 1;
|
|
37
|
+
/**
|
|
38
|
+
* One booking, for older agents and scripts that read only this field: the server's `activeRequest`
|
|
39
|
+
* when the agent knows it (an older agent drops the booking unless it is that one), else the newest.
|
|
40
|
+
*/
|
|
41
|
+
booking: PersistedBooking;
|
|
42
|
+
/** Every active booking, newest first. Files from older agents only have `booking`. */
|
|
43
|
+
bookings?: PersistedBooking[];
|
|
44
|
+
updatedAt: number;
|
|
45
|
+
};
|
|
46
|
+
export declare function statePath(env?: NodeJS.ProcessEnv): string;
|
|
47
|
+
/** Reads state.json. The result always has `bookings`; a file with only the legacy `booking` field becomes a list of one. */
|
|
48
|
+
export declare function readAgentState(env?: NodeJS.ProcessEnv): Promise<(PersistedState & {
|
|
49
|
+
bookings: PersistedBooking[];
|
|
50
|
+
}) | null>;
|
|
51
|
+
export declare function saveAgentState(state: PersistedState, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
52
|
+
export declare function clearAgentState(env?: NodeJS.ProcessEnv): Promise<void>;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { subletHome } from './config.js';
|
|
4
|
+
import { atomicWrite } from './util/fs.js';
|
|
5
|
+
import { finiteNumber, isRequestId, validTime } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* A saved booking with every number checked (a hand-edited or damaged file can hold strings, NaN or
|
|
8
|
+
* nonsense), or null when it has no valid request id, model or end time.
|
|
9
|
+
*/
|
|
10
|
+
function readBooking(value) {
|
|
11
|
+
if (!value || typeof value !== 'object')
|
|
12
|
+
return null;
|
|
13
|
+
const raw = value;
|
|
14
|
+
if (!isRequestId(raw.requestId) || !raw.model || typeof raw.model !== 'object')
|
|
15
|
+
return null;
|
|
16
|
+
const timerEndsAt = validTime(raw.timerEndsAt);
|
|
17
|
+
if (timerEndsAt == null)
|
|
18
|
+
return null;
|
|
19
|
+
const booking = {
|
|
20
|
+
...raw,
|
|
21
|
+
context: finiteNumber(raw.context) ?? 0,
|
|
22
|
+
slots: finiteNumber(raw.slots) ?? 1,
|
|
23
|
+
timerStartedAt: validTime(raw.timerStartedAt),
|
|
24
|
+
timerEndsAt,
|
|
25
|
+
hours: finiteNumber(raw.hours),
|
|
26
|
+
pricePerHourNanos: finiteNumber(raw.pricePerHourNanos),
|
|
27
|
+
minTokS: finiteNumber(raw.minTokS),
|
|
28
|
+
guaranteedNanos: finiteNumber(raw.guaranteedNanos),
|
|
29
|
+
savedAt: finiteNumber(raw.savedAt) ?? 0,
|
|
30
|
+
};
|
|
31
|
+
if (raw.tokS !== undefined)
|
|
32
|
+
booking.tokS = finiteNumber(raw.tokS);
|
|
33
|
+
if (raw.shared !== undefined)
|
|
34
|
+
booking.shared = raw.shared === true;
|
|
35
|
+
return booking;
|
|
36
|
+
}
|
|
37
|
+
export function statePath(env = process.env) {
|
|
38
|
+
return join(subletHome(env), 'state.json');
|
|
39
|
+
}
|
|
40
|
+
/** Reads state.json. The result always has `bookings`; a file with only the legacy `booking` field becomes a list of one. */
|
|
41
|
+
export async function readAgentState(env = process.env) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(statePath(env), 'utf8');
|
|
44
|
+
const data = JSON.parse(raw);
|
|
45
|
+
if (!data || data.version !== 1)
|
|
46
|
+
return null;
|
|
47
|
+
const bookings = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
// New files repeat one of the bookings in `booking`; the requestId check drops the copy.
|
|
50
|
+
for (const entry of [...(Array.isArray(data.bookings) ? data.bookings : []), data.booking]) {
|
|
51
|
+
const booking = readBooking(entry);
|
|
52
|
+
if (!booking || seen.has(booking.requestId))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(booking.requestId);
|
|
55
|
+
bookings.push(booking);
|
|
56
|
+
}
|
|
57
|
+
if (!bookings.length)
|
|
58
|
+
return null;
|
|
59
|
+
return { version: 1, booking: bookings[0], bookings, updatedAt: Number(data.updatedAt) || 0 };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function saveAgentState(state, env = process.env) {
|
|
66
|
+
await atomicWrite(statePath(env), `${JSON.stringify(state, null, 2)}\n`);
|
|
67
|
+
}
|
|
68
|
+
export async function clearAgentState(env = process.env) {
|
|
69
|
+
await rm(statePath(env), { force: true });
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Maintenance is deliberately serialized against serving. Only regular stale
|
|
2
|
+
* partial files in the managed model root are eligible, never complete models. */
|
|
3
|
+
export declare function storageReport(prune: boolean, apply: boolean, env?: NodeJS.ProcessEnv): Promise<{
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
home: string;
|
|
6
|
+
policy: {
|
|
7
|
+
modelCacheBytes: number;
|
|
8
|
+
reserveBytes: number;
|
|
9
|
+
};
|
|
10
|
+
freeBytes: number | null;
|
|
11
|
+
usage: Record<string, number>;
|
|
12
|
+
prune: boolean;
|
|
13
|
+
dryRun: boolean;
|
|
14
|
+
candidates: {
|
|
15
|
+
path: string;
|
|
16
|
+
bytes: number;
|
|
17
|
+
}[];
|
|
18
|
+
}>;
|
|
19
|
+
export declare function storageAction(positionals: string[], force: boolean): {
|
|
20
|
+
prune: boolean;
|
|
21
|
+
apply: boolean;
|
|
22
|
+
};
|