@mlx-node/server 0.0.8 → 0.0.10
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/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +8 -8
- package/dist/chat-session-warm-reuse.d.ts.map +1 -1
- package/dist/chat-session-warm-reuse.js +12 -8
- package/dist/endpoints/responses.d.ts +3 -1
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +38 -5
- package/dist/handler.d.ts +27 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +65 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/model-work-coordinator.d.ts +29 -4
- package/dist/model-work-coordinator.d.ts.map +1 -1
- package/dist/model-work-coordinator.js +97 -16
- package/dist/router.d.ts +34 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +47 -5
- package/dist/server.d.ts +117 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +125 -9
- package/dist/session-registry.d.ts +7 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +9 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/package.json +15 -3
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request/response logger for `mlx launch claude --verbose`.
|
|
3
|
+
*
|
|
4
|
+
* Each HTTP turn is written as one line of newline-delimited JSON to
|
|
5
|
+
* `requests.ndjson`. Streaming responses capture every chunk written
|
|
6
|
+
* to the socket so SSE events land verbatim — enough to audit cache
|
|
7
|
+
* hits (`x-session-cache` header), tool-call round-trips, and the
|
|
8
|
+
* model's token-level output post-hoc.
|
|
9
|
+
*
|
|
10
|
+
* `session.log` is the human-readable companion: one line per request
|
|
11
|
+
* arrival and completion, for `tail -f` during a live session.
|
|
12
|
+
*/
|
|
13
|
+
import { Buffer } from 'node:buffer';
|
|
14
|
+
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/**
|
|
17
|
+
* Headers whose value is a credential and must never reach the log file.
|
|
18
|
+
*
|
|
19
|
+
* The log lands in `~/.mlx-node/logs/<timestamp>/requests.ndjson`, which users
|
|
20
|
+
* paste into bug reports, and every client of a protected host presents a live
|
|
21
|
+
* secret on every turn: the per-launch token `mlx launch claude` generates, or
|
|
22
|
+
* `MLX_SERVER_AUTH_TOKEN` — or, before the launcher stopped handing it down,
|
|
23
|
+
* the user's own `sk-ant-…` key.
|
|
24
|
+
*/
|
|
25
|
+
const REDACTED_REQUEST_HEADERS = ['authorization', 'x-api-key', 'proxy-authorization', 'cookie'];
|
|
26
|
+
/**
|
|
27
|
+
* Copy `headers`, replacing credential values with a marker.
|
|
28
|
+
*
|
|
29
|
+
* The KEY is kept: "a token was presented and it was wrong" and "no token was
|
|
30
|
+
* presented at all" are different bugs, and a log that drops the header cannot
|
|
31
|
+
* tell them apart. Node lower-cases incoming header names, so the comparison
|
|
32
|
+
* needs no folding.
|
|
33
|
+
*/
|
|
34
|
+
function redactRequestHeaders(headers) {
|
|
35
|
+
let copy = null;
|
|
36
|
+
for (const name of REDACTED_REQUEST_HEADERS) {
|
|
37
|
+
if (headers[name] === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
copy ??= { ...headers };
|
|
40
|
+
copy[name] = '[redacted]';
|
|
41
|
+
}
|
|
42
|
+
return copy ?? headers;
|
|
43
|
+
}
|
|
44
|
+
function parseJsonObject(value) {
|
|
45
|
+
if (!value)
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(value);
|
|
49
|
+
return parsed != null && typeof parsed === 'object' ? parsed : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function asUsage(value) {
|
|
56
|
+
return value != null && typeof value === 'object' ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
function fmtMs(ms) {
|
|
59
|
+
return typeof ms === 'number' && Number.isFinite(ms) ? `${Math.round(ms)}ms` : undefined;
|
|
60
|
+
}
|
|
61
|
+
function fmtRate(rate) {
|
|
62
|
+
return typeof rate === 'number' && Number.isFinite(rate) ? `${rate.toFixed(2)}/s` : undefined;
|
|
63
|
+
}
|
|
64
|
+
function addMs(left, right) {
|
|
65
|
+
return typeof left === 'number' && Number.isFinite(left) && typeof right === 'number' && Number.isFinite(right)
|
|
66
|
+
? left + right
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
function extractUsageSummary(resBody) {
|
|
70
|
+
let model;
|
|
71
|
+
let usage;
|
|
72
|
+
let stop;
|
|
73
|
+
const trimmed = resBody.trimStart();
|
|
74
|
+
if (trimmed.startsWith('{')) {
|
|
75
|
+
const json = parseJsonObject(trimmed);
|
|
76
|
+
if (json) {
|
|
77
|
+
const response = json.response != null && typeof json.response === 'object' ? json.response : json;
|
|
78
|
+
model = typeof response.model === 'string' ? response.model : undefined;
|
|
79
|
+
usage = asUsage(response.usage);
|
|
80
|
+
stop = typeof response.stop_reason === 'string' ? response.stop_reason : undefined;
|
|
81
|
+
return { model, usage, stop };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const line of resBody.split('\n')) {
|
|
85
|
+
if (!line.startsWith('data: '))
|
|
86
|
+
continue;
|
|
87
|
+
const payload = line.slice(6);
|
|
88
|
+
if (payload === '[DONE]')
|
|
89
|
+
continue;
|
|
90
|
+
const event = parseJsonObject(payload);
|
|
91
|
+
if (!event)
|
|
92
|
+
continue;
|
|
93
|
+
if (event.type === 'message_start' && event.message != null && typeof event.message === 'object') {
|
|
94
|
+
const message = event.message;
|
|
95
|
+
if (typeof message.model === 'string')
|
|
96
|
+
model = message.model;
|
|
97
|
+
}
|
|
98
|
+
if (event.type === 'message_delta') {
|
|
99
|
+
usage = asUsage(event.usage) ?? usage;
|
|
100
|
+
const delta = event.delta != null && typeof event.delta === 'object' ? event.delta : null;
|
|
101
|
+
if (typeof delta?.stop_reason === 'string')
|
|
102
|
+
stop = delta.stop_reason;
|
|
103
|
+
}
|
|
104
|
+
if ((event.type === 'response.completed' || event.type === 'response.failed') &&
|
|
105
|
+
event.response != null &&
|
|
106
|
+
typeof event.response === 'object') {
|
|
107
|
+
const response = event.response;
|
|
108
|
+
if (typeof response.model === 'string')
|
|
109
|
+
model = response.model;
|
|
110
|
+
usage = asUsage(response.usage) ?? usage;
|
|
111
|
+
if (typeof response.status === 'string')
|
|
112
|
+
stop = response.status;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { model, usage, stop };
|
|
116
|
+
}
|
|
117
|
+
function buildTimingSummary(reqBody, resBody) {
|
|
118
|
+
const request = parseJsonObject(reqBody);
|
|
119
|
+
const response = extractUsageSummary(resBody);
|
|
120
|
+
const model = typeof request?.model === 'string' ? request.model : response.model;
|
|
121
|
+
const usage = response.usage;
|
|
122
|
+
if (!usage && !model)
|
|
123
|
+
return '';
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (model)
|
|
126
|
+
parts.push(`model=${model}`);
|
|
127
|
+
if (usage) {
|
|
128
|
+
const cachedTokens = usage.cache_read_input_tokens ?? usage.cached_prefix_tokens ?? usage.input_tokens_details?.cached_tokens;
|
|
129
|
+
const tokenParts = [
|
|
130
|
+
typeof usage.input_tokens === 'number' ? `in=${usage.input_tokens}` : undefined,
|
|
131
|
+
typeof cachedTokens === 'number' ? `cache=${cachedTokens}` : undefined,
|
|
132
|
+
typeof usage.prefill_input_tokens === 'number' ? `prefill=${usage.prefill_input_tokens}` : undefined,
|
|
133
|
+
typeof usage.output_tokens === 'number' ? `out=${usage.output_tokens}` : undefined,
|
|
134
|
+
].filter((part) => part != null);
|
|
135
|
+
if (tokenParts.length > 0)
|
|
136
|
+
parts.push(`tok(${tokenParts.join(' ')})`);
|
|
137
|
+
const totalFirstTokenMs = usage.server_total_time_to_first_token_ms ?? addMs(usage.server_pre_inference_ms, usage.time_to_first_token_ms);
|
|
138
|
+
const timingParts = [
|
|
139
|
+
fmtMs(totalFirstTokenMs) ? `ttfb=${fmtMs(totalFirstTokenMs)}` : undefined,
|
|
140
|
+
fmtMs(usage.time_to_first_token_ms) ? `ttft=${fmtMs(usage.time_to_first_token_ms)}` : undefined,
|
|
141
|
+
fmtRate(usage.prefill_tokens_per_second) ? `prefill=${fmtRate(usage.prefill_tokens_per_second)}` : undefined,
|
|
142
|
+
fmtRate(usage.decode_tokens_per_second) ? `decode=${fmtRate(usage.decode_tokens_per_second)}` : undefined,
|
|
143
|
+
fmtMs(usage.server_inference_elapsed_ms) ? `infer=${fmtMs(usage.server_inference_elapsed_ms)}` : undefined,
|
|
144
|
+
].filter((part) => part != null);
|
|
145
|
+
if (timingParts.length > 0)
|
|
146
|
+
parts.push(`perf(${timingParts.join(' ')})`);
|
|
147
|
+
const serverParts = [
|
|
148
|
+
fmtMs(usage.server_model_resolve_ms) ? `resolve=${fmtMs(usage.server_model_resolve_ms)}` : undefined,
|
|
149
|
+
fmtMs(usage.server_load_wait_ms) ? `load_wait=${fmtMs(usage.server_load_wait_ms)}` : undefined,
|
|
150
|
+
typeof usage.server_load_owner === 'boolean' ? `load_owner=${usage.server_load_owner}` : undefined,
|
|
151
|
+
fmtMs(usage.server_queue_ms) ? `queue=${fmtMs(usage.server_queue_ms)}` : undefined,
|
|
152
|
+
fmtMs(usage.server_pre_inference_ms) ? `pre=${fmtMs(usage.server_pre_inference_ms)}` : undefined,
|
|
153
|
+
].filter((part) => part != null);
|
|
154
|
+
if (serverParts.length > 0)
|
|
155
|
+
parts.push(`server(${serverParts.join(' ')})`);
|
|
156
|
+
const tuningParts = [
|
|
157
|
+
typeof usage.server_paged_prefill_chunk_size === 'number'
|
|
158
|
+
? `prefill_chunk=${usage.server_paged_prefill_chunk_size}`
|
|
159
|
+
: undefined,
|
|
160
|
+
typeof usage.server_paged_prefill_eval_interval === 'number'
|
|
161
|
+
? `prefill_eval=${usage.server_paged_prefill_eval_interval}`
|
|
162
|
+
: undefined,
|
|
163
|
+
typeof usage.server_paged_decode_cache_clear_interval === 'number'
|
|
164
|
+
? `decode_clear=${usage.server_paged_decode_cache_clear_interval}`
|
|
165
|
+
: undefined,
|
|
166
|
+
].filter((part) => part != null);
|
|
167
|
+
if (tuningParts.length > 0)
|
|
168
|
+
parts.push(`tune(${tuningParts.join(' ')})`);
|
|
169
|
+
}
|
|
170
|
+
if (response.stop)
|
|
171
|
+
parts.push(`stop=${response.stop}`);
|
|
172
|
+
return parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
173
|
+
}
|
|
174
|
+
function buildRequestBodySummary(reqBody) {
|
|
175
|
+
const request = parseJsonObject(reqBody);
|
|
176
|
+
if (!request)
|
|
177
|
+
return '';
|
|
178
|
+
const parts = [];
|
|
179
|
+
if (typeof request.model === 'string')
|
|
180
|
+
parts.push(`model=${request.model}`);
|
|
181
|
+
if (typeof request.max_tokens === 'number')
|
|
182
|
+
parts.push(`max_tokens=${request.max_tokens}`);
|
|
183
|
+
if (typeof request.stream === 'boolean')
|
|
184
|
+
parts.push(`stream=${request.stream}`);
|
|
185
|
+
if (Array.isArray(request.messages))
|
|
186
|
+
parts.push(`messages=${request.messages.length}`);
|
|
187
|
+
if (Array.isArray(request.tools))
|
|
188
|
+
parts.push(`tools=${request.tools.length}`);
|
|
189
|
+
if (typeof request.system === 'string') {
|
|
190
|
+
parts.push(`system=string`);
|
|
191
|
+
}
|
|
192
|
+
else if (Array.isArray(request.system)) {
|
|
193
|
+
parts.push(`system=blocks:${request.system.length}`);
|
|
194
|
+
}
|
|
195
|
+
return parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Attach request/response capture to `server`. Call `close()` AFTER
|
|
199
|
+
* `server.close()` resolves: the completion listeners below fire when a
|
|
200
|
+
* response finishes, so ending the streams first drops the tail of every
|
|
201
|
+
* request still in flight.
|
|
202
|
+
*/
|
|
203
|
+
export function attachLogger(server, logDir) {
|
|
204
|
+
mkdirSync(logDir, { recursive: true });
|
|
205
|
+
const reqLog = createWriteStream(join(logDir, 'requests.ndjson'), { flags: 'a' });
|
|
206
|
+
const pretty = createWriteStream(join(logDir, 'session.log'), { flags: 'a' });
|
|
207
|
+
// A write that lands after `end()` reports `ERR_STREAM_WRITE_AFTER_END`
|
|
208
|
+
// asynchronously, as an `error` event — the `try`/`catch` around each
|
|
209
|
+
// `write()` below cannot see it. With no listener that event is fatal, so
|
|
210
|
+
// a request completing during shutdown would take the process down instead
|
|
211
|
+
// of finishing the graceful close. Ordering close correctly is what keeps
|
|
212
|
+
// the record intact; these listeners are what keep a lost log line from
|
|
213
|
+
// being lethal when the ordering cannot help — the forced-close path emits
|
|
214
|
+
// `res.on('close')` a tick after `server.close()` has already resolved.
|
|
215
|
+
const discardStreamError = () => { };
|
|
216
|
+
reqLog.on('error', discardStreamError);
|
|
217
|
+
pretty.on('error', discardStreamError);
|
|
218
|
+
const writePretty = (line) => {
|
|
219
|
+
try {
|
|
220
|
+
pretty.write(`${new Date().toISOString()} ${line}\n`);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* never let logging failure break serving */
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
writePretty(`[logging] writing to ${logDir}`);
|
|
227
|
+
writePretty(`[logging] requests.ndjson — one JSON line per HTTP turn (full body in/out, SSE chunks)`);
|
|
228
|
+
writePretty(`[logging] session.log — human-readable chronological trace`);
|
|
229
|
+
if (process.env.MLX_INFERENCE_TRACE_FILE) {
|
|
230
|
+
writePretty(`[logging] inference trace — ${process.env.MLX_INFERENCE_TRACE_FILE}`);
|
|
231
|
+
}
|
|
232
|
+
// Node's http.Server multicasts request events — our listener fires
|
|
233
|
+
// alongside the createServer handler. Dedupe in case the same
|
|
234
|
+
// request ever re-enters (paranoia; it shouldn't).
|
|
235
|
+
const seen = new WeakSet();
|
|
236
|
+
// Prepend so our listener runs BEFORE the main handler. This is
|
|
237
|
+
// what lets the write/end wrappers land before any synchronous
|
|
238
|
+
// response path writes — a sync handler (easy to hit in tests)
|
|
239
|
+
// would otherwise miss the wrap entirely.
|
|
240
|
+
server.prependListener('request', (req, res) => {
|
|
241
|
+
if (seen.has(req))
|
|
242
|
+
return;
|
|
243
|
+
seen.add(req);
|
|
244
|
+
const start = Date.now();
|
|
245
|
+
const rid = `${start.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
246
|
+
writePretty(`[req ${rid}] ${req.method ?? '?'} ${req.url ?? '?'}`);
|
|
247
|
+
let reqBody = '';
|
|
248
|
+
req.on('data', (chunk) => {
|
|
249
|
+
reqBody += chunk.toString('utf8');
|
|
250
|
+
});
|
|
251
|
+
// Wrap res.write / res.end to capture streamed chunks (SSE + regular).
|
|
252
|
+
// Preserve original method semantics — we only observe, never transform.
|
|
253
|
+
const chunks = [];
|
|
254
|
+
const origWrite = res.write.bind(res);
|
|
255
|
+
const origEnd = res.end.bind(res);
|
|
256
|
+
// biome-ignore lint/suspicious/noExplicitAny: capture wrapper
|
|
257
|
+
res.write = (chunk, ...rest) => {
|
|
258
|
+
if (chunk != null) {
|
|
259
|
+
try {
|
|
260
|
+
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return origWrite(chunk, ...rest);
|
|
267
|
+
};
|
|
268
|
+
// biome-ignore lint/suspicious/noExplicitAny: capture wrapper
|
|
269
|
+
res.end = (chunk, ...rest) => {
|
|
270
|
+
if (chunk != null) {
|
|
271
|
+
try {
|
|
272
|
+
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* ignore */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return origEnd(chunk, ...rest);
|
|
279
|
+
};
|
|
280
|
+
// Write the NDJSON entry when BOTH the request body has been fully
|
|
281
|
+
// consumed (`req.on('end')`) AND the response has fully flushed
|
|
282
|
+
// (`res.on('finish')`). Necessary because a synchronous handler can
|
|
283
|
+
// call `res.end()` before our `req.on('data')` listener has seen
|
|
284
|
+
// any chunks — the stream was set flowing on `.on('data', ...)`
|
|
285
|
+
// but the chunks deliver in a later tick.
|
|
286
|
+
let reqDone = false;
|
|
287
|
+
let resDone = false;
|
|
288
|
+
let emitted = false;
|
|
289
|
+
let requestBodyLogged = false;
|
|
290
|
+
const logRequestBody = (phase) => {
|
|
291
|
+
if (requestBodyLogged)
|
|
292
|
+
return;
|
|
293
|
+
requestBodyLogged = true;
|
|
294
|
+
writePretty(`[req ${rid}] request_body_${phase} ${Buffer.byteLength(reqBody, 'utf8')}B${buildRequestBodySummary(reqBody)}`);
|
|
295
|
+
};
|
|
296
|
+
const tryEmit = () => {
|
|
297
|
+
if (emitted || !reqDone || !resDone)
|
|
298
|
+
return;
|
|
299
|
+
emitted = true;
|
|
300
|
+
const resBody = chunks.join('');
|
|
301
|
+
const entry = {
|
|
302
|
+
rid,
|
|
303
|
+
t: new Date(start).toISOString(),
|
|
304
|
+
method: req.method,
|
|
305
|
+
path: req.url,
|
|
306
|
+
reqHeaders: redactRequestHeaders(req.headers),
|
|
307
|
+
reqBody: reqBody || null,
|
|
308
|
+
status: res.statusCode,
|
|
309
|
+
resHeaders: res.getHeaders(),
|
|
310
|
+
resBody,
|
|
311
|
+
elapsedMs: Date.now() - start,
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
reqLog.write(`${JSON.stringify(entry)}\n`);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* never block the response */
|
|
318
|
+
}
|
|
319
|
+
const timingSummary = buildTimingSummary(reqBody, resBody);
|
|
320
|
+
writePretty(`[req ${rid}] ${res.statusCode} ${req.method ?? '?'} ${req.url ?? '?'} ${entry.elapsedMs}ms ${resBody.length}B${timingSummary}`);
|
|
321
|
+
};
|
|
322
|
+
req.on('end', () => {
|
|
323
|
+
reqDone = true;
|
|
324
|
+
logRequestBody('end');
|
|
325
|
+
tryEmit();
|
|
326
|
+
});
|
|
327
|
+
req.on('close', () => {
|
|
328
|
+
// Client may drop the body mid-flight; emit whatever we have.
|
|
329
|
+
reqDone = true;
|
|
330
|
+
logRequestBody('close');
|
|
331
|
+
tryEmit();
|
|
332
|
+
});
|
|
333
|
+
res.on('finish', () => {
|
|
334
|
+
resDone = true;
|
|
335
|
+
tryEmit();
|
|
336
|
+
});
|
|
337
|
+
res.on('close', () => {
|
|
338
|
+
if (!res.writableEnded) {
|
|
339
|
+
writePretty(`[req ${rid}] aborted (client close) after ${Date.now() - start}ms`);
|
|
340
|
+
}
|
|
341
|
+
// Abort path: treat as done so we still emit what we captured.
|
|
342
|
+
resDone = true;
|
|
343
|
+
tryEmit();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
let closed = false;
|
|
347
|
+
return {
|
|
348
|
+
logDir,
|
|
349
|
+
async close() {
|
|
350
|
+
if (closed)
|
|
351
|
+
return;
|
|
352
|
+
closed = true;
|
|
353
|
+
await Promise.all([
|
|
354
|
+
new Promise((resolve) => reqLog.end(resolve)),
|
|
355
|
+
new Promise((resolve) => pretty.end(resolve)),
|
|
356
|
+
]);
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Resolve the log directory for a verbose launch.
|
|
362
|
+
*
|
|
363
|
+
* Order: explicit `--log-dir` > `MLX_LOG_DIR` env > a fresh timestamped
|
|
364
|
+
* directory under `<mlxNodeHome>/logs/`. The timestamped default gives
|
|
365
|
+
* each launch its own dir so concurrent / sequential runs don't
|
|
366
|
+
* interleave into one file.
|
|
367
|
+
*/
|
|
368
|
+
export function resolveLogDir(explicit, mlxNodeHome) {
|
|
369
|
+
if (explicit)
|
|
370
|
+
return explicit;
|
|
371
|
+
const envDir = process.env.MLX_LOG_DIR;
|
|
372
|
+
if (envDir)
|
|
373
|
+
return envDir;
|
|
374
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
375
|
+
return join(mlxNodeHome, 'logs', stamp);
|
|
376
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Bind-address and port helpers shared by every inference host front-end. */
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a bare IPv6 literal in `[...]` so it is safe inside a URL authority.
|
|
4
|
+
*
|
|
5
|
+
* Any host carrying a `:` that is not already bracketed is an IPv6 literal
|
|
6
|
+
* (`::1`, `2001:db8::1`, or a scoped `fe80::1%en0`); an unbracketed one makes
|
|
7
|
+
* the trailing `:<port>` ambiguous and `new URL()` reject it. IPv4/hostnames
|
|
8
|
+
* (no `:`) and already-bracketed literals pass through unchanged. A scoped
|
|
9
|
+
* literal is bracketed too: `new URL` still rejects the `%`, but the printed
|
|
10
|
+
* string stays well-formed rather than crashing the caller.
|
|
11
|
+
*
|
|
12
|
+
* `@mlx-node/dashboard` carries its own copy for its own bind logic; this one
|
|
13
|
+
* is the inference host's, so the two can diverge without coupling.
|
|
14
|
+
*/
|
|
15
|
+
export declare function bracketHost(host: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Convert the one bracketed bind form the host security policy accepts into
|
|
18
|
+
* the bare literal Node's `server.listen()` requires.
|
|
19
|
+
*
|
|
20
|
+
* Brackets belong to URL authorities, not socket bind addresses: passing
|
|
21
|
+
* `[::1]` to Node performs a DNS lookup and fails with `ENOTFOUND`. This is
|
|
22
|
+
* deliberately exact rather than a general bracket stripper. In particular,
|
|
23
|
+
* `[::]` and bracketed routable IPv6 text must not be turned into working
|
|
24
|
+
* wildcard/network binds by a helper intended only to repair loopback.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeLoopbackBindHost(host: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Build the URL a client should connect to for a `(host, port)` bind.
|
|
29
|
+
*
|
|
30
|
+
* A wildcard bind has no connectable literal host, so advertise the matching
|
|
31
|
+
* loopback instead: `::` binds IPv6 → `[::1]`, `0.0.0.0`/`''` bind IPv4 →
|
|
32
|
+
* `127.0.0.1`. A concrete host is advertised as-is with any IPv6 literal
|
|
33
|
+
* bracketed.
|
|
34
|
+
*/
|
|
35
|
+
export declare function hostUrl(host: string, port: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* Does this BIND address keep the socket unreachable from another machine?
|
|
38
|
+
*
|
|
39
|
+
* Distinct from the desktop supervisor's `isLoopbackHttpUrl`, which classifies
|
|
40
|
+
* a URL a client would connect TO. This one classifies the string handed to
|
|
41
|
+
* `server.listen(port, host)`, where the failure modes are different: the
|
|
42
|
+
* wildcards (`0.0.0.0`, `::`, `''`) are not routable addresses at all yet bind
|
|
43
|
+
* every interface, and `hostUrl` deliberately ADVERTISES them as loopback — so
|
|
44
|
+
* a check written against the advertised URL would call a wildcard bind safe.
|
|
45
|
+
*
|
|
46
|
+
* Loopback: the whole `127.0.0.0/8` block, `::1` (bracketed or not), and
|
|
47
|
+
* `localhost`. Anything else — a LAN address, a wildcard, a hostname that
|
|
48
|
+
* resolves off-box — is treated as reachable, because a predicate that has to
|
|
49
|
+
* resolve DNS to answer would either block startup or guess.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isLoopbackBindHost(host: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Ask the kernel for a free port by binding to port 0, reading the assigned
|
|
54
|
+
* port, and closing immediately.
|
|
55
|
+
*
|
|
56
|
+
* Racy in theory (another process could grab the port between the close and
|
|
57
|
+
* the real listen), but acceptable for a developer-local host. Callers that
|
|
58
|
+
* cannot tolerate the race should pass `port: 0` straight through to
|
|
59
|
+
* `createServer` and read the bound port back off the socket instead — the
|
|
60
|
+
* inference host does exactly that when no port is requested at all; this
|
|
61
|
+
* helper exists for the case where the port must be known BEFORE the server
|
|
62
|
+
* starts (e.g. baking `ANTHROPIC_BASE_URL` into a child's env).
|
|
63
|
+
*/
|
|
64
|
+
export declare function pickFreePort(): Promise<number>;
|
|
65
|
+
//# sourceMappingURL=net.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net.d.ts","sourceRoot":"","sources":["../../src/host/net.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAI9E;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAMxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAepD"}
|
package/dist/host/net.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Bind-address and port helpers shared by every inference host front-end. */
|
|
2
|
+
import { createServer as netCreateServer } from 'node:net';
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a bare IPv6 literal in `[...]` so it is safe inside a URL authority.
|
|
5
|
+
*
|
|
6
|
+
* Any host carrying a `:` that is not already bracketed is an IPv6 literal
|
|
7
|
+
* (`::1`, `2001:db8::1`, or a scoped `fe80::1%en0`); an unbracketed one makes
|
|
8
|
+
* the trailing `:<port>` ambiguous and `new URL()` reject it. IPv4/hostnames
|
|
9
|
+
* (no `:`) and already-bracketed literals pass through unchanged. A scoped
|
|
10
|
+
* literal is bracketed too: `new URL` still rejects the `%`, but the printed
|
|
11
|
+
* string stays well-formed rather than crashing the caller.
|
|
12
|
+
*
|
|
13
|
+
* `@mlx-node/dashboard` carries its own copy for its own bind logic; this one
|
|
14
|
+
* is the inference host's, so the two can diverge without coupling.
|
|
15
|
+
*/
|
|
16
|
+
export function bracketHost(host) {
|
|
17
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Convert the one bracketed bind form the host security policy accepts into
|
|
21
|
+
* the bare literal Node's `server.listen()` requires.
|
|
22
|
+
*
|
|
23
|
+
* Brackets belong to URL authorities, not socket bind addresses: passing
|
|
24
|
+
* `[::1]` to Node performs a DNS lookup and fails with `ENOTFOUND`. This is
|
|
25
|
+
* deliberately exact rather than a general bracket stripper. In particular,
|
|
26
|
+
* `[::]` and bracketed routable IPv6 text must not be turned into working
|
|
27
|
+
* wildcard/network binds by a helper intended only to repair loopback.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeLoopbackBindHost(host) {
|
|
30
|
+
return host === '[::1]' ? '::1' : host;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the URL a client should connect to for a `(host, port)` bind.
|
|
34
|
+
*
|
|
35
|
+
* A wildcard bind has no connectable literal host, so advertise the matching
|
|
36
|
+
* loopback instead: `::` binds IPv6 → `[::1]`, `0.0.0.0`/`''` bind IPv4 →
|
|
37
|
+
* `127.0.0.1`. A concrete host is advertised as-is with any IPv6 literal
|
|
38
|
+
* bracketed.
|
|
39
|
+
*/
|
|
40
|
+
export function hostUrl(host, port) {
|
|
41
|
+
const wildcard = host === '0.0.0.0' || host === '::' || host === '';
|
|
42
|
+
const displayHost = wildcard ? (host === '::' ? '[::1]' : '127.0.0.1') : bracketHost(host);
|
|
43
|
+
return `http://${displayHost}:${port}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Does this BIND address keep the socket unreachable from another machine?
|
|
47
|
+
*
|
|
48
|
+
* Distinct from the desktop supervisor's `isLoopbackHttpUrl`, which classifies
|
|
49
|
+
* a URL a client would connect TO. This one classifies the string handed to
|
|
50
|
+
* `server.listen(port, host)`, where the failure modes are different: the
|
|
51
|
+
* wildcards (`0.0.0.0`, `::`, `''`) are not routable addresses at all yet bind
|
|
52
|
+
* every interface, and `hostUrl` deliberately ADVERTISES them as loopback — so
|
|
53
|
+
* a check written against the advertised URL would call a wildcard bind safe.
|
|
54
|
+
*
|
|
55
|
+
* Loopback: the whole `127.0.0.0/8` block, `::1` (bracketed or not), and
|
|
56
|
+
* `localhost`. Anything else — a LAN address, a wildcard, a hostname that
|
|
57
|
+
* resolves off-box — is treated as reachable, because a predicate that has to
|
|
58
|
+
* resolve DNS to answer would either block startup or guess.
|
|
59
|
+
*/
|
|
60
|
+
export function isLoopbackBindHost(host) {
|
|
61
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]')
|
|
62
|
+
return true;
|
|
63
|
+
// `::ffff:127.0.0.1` is the IPv4-mapped form Node accepts on a dual-stack
|
|
64
|
+
// socket; it binds the same loopback interface.
|
|
65
|
+
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/i.test(host))
|
|
66
|
+
return true;
|
|
67
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Ask the kernel for a free port by binding to port 0, reading the assigned
|
|
71
|
+
* port, and closing immediately.
|
|
72
|
+
*
|
|
73
|
+
* Racy in theory (another process could grab the port between the close and
|
|
74
|
+
* the real listen), but acceptable for a developer-local host. Callers that
|
|
75
|
+
* cannot tolerate the race should pass `port: 0` straight through to
|
|
76
|
+
* `createServer` and read the bound port back off the socket instead — the
|
|
77
|
+
* inference host does exactly that when no port is requested at all; this
|
|
78
|
+
* helper exists for the case where the port must be known BEFORE the server
|
|
79
|
+
* starts (e.g. baking `ANTHROPIC_BASE_URL` into a child's env).
|
|
80
|
+
*/
|
|
81
|
+
export async function pickFreePort() {
|
|
82
|
+
return await new Promise((resolve, reject) => {
|
|
83
|
+
const probe = netCreateServer();
|
|
84
|
+
probe.unref();
|
|
85
|
+
probe.once('error', reject);
|
|
86
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
87
|
+
const addr = probe.address();
|
|
88
|
+
if (addr && typeof addr === 'object') {
|
|
89
|
+
const port = addr.port;
|
|
90
|
+
probe.close(() => resolve(port));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
probe.close(() => reject(new Error('could not determine free port')));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `$HOME/.mlx-node` layout helpers. Drives `mlx download model`
|
|
3
|
+
* (output destination), every inference host (`mlx serve`, `mlx launch
|
|
4
|
+
* claude`, the desktop sidecar) as the model discovery root, and the
|
|
5
|
+
* default log root.
|
|
6
|
+
*
|
|
7
|
+
* Published as its own `@mlx-node/server/host/paths` subpath, and it must
|
|
8
|
+
* stay a dependency-free leaf. `@mlx-node/server/host` value-imports
|
|
9
|
+
* `@mlx-node/lm`, which dlopens the native addon at module scope; commands
|
|
10
|
+
* that only need to know where models live — `mlx download model` above all,
|
|
11
|
+
* the one you run BEFORE you have anything to run — must not pay for that,
|
|
12
|
+
* and on a headless box the dlopen is a liability rather than a cost.
|
|
13
|
+
*/
|
|
14
|
+
/** Absolute path to `$HOME/.mlx-node`. Used for `config.json` lookup and as the default parent of `models/`. */
|
|
15
|
+
export declare function resolveMlxNodeHome(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the directory where downloaded models live.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order:
|
|
20
|
+
* 1. `explicit` arg (non-empty)
|
|
21
|
+
* 2. `MLX_MODELS_DIR` env var
|
|
22
|
+
* 3. `modelsDir` field in `$HOME/.mlx-node/config.json`
|
|
23
|
+
* 4. `$HOME/.mlx-node/models`
|
|
24
|
+
*
|
|
25
|
+
* Creates the chosen directory (recursive) before returning.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveModelsDir(explicit?: string): string;
|
|
28
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/host/paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,gHAAgH;AAChH,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAiB1D"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `$HOME/.mlx-node` layout helpers. Drives `mlx download model`
|
|
3
|
+
* (output destination), every inference host (`mlx serve`, `mlx launch
|
|
4
|
+
* claude`, the desktop sidecar) as the model discovery root, and the
|
|
5
|
+
* default log root.
|
|
6
|
+
*
|
|
7
|
+
* Published as its own `@mlx-node/server/host/paths` subpath, and it must
|
|
8
|
+
* stay a dependency-free leaf. `@mlx-node/server/host` value-imports
|
|
9
|
+
* `@mlx-node/lm`, which dlopens the native addon at module scope; commands
|
|
10
|
+
* that only need to know where models live — `mlx download model` above all,
|
|
11
|
+
* the one you run BEFORE you have anything to run — must not pay for that,
|
|
12
|
+
* and on a headless box the dlopen is a liability rather than a cost.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
/** Absolute path to `$HOME/.mlx-node`. Used for `config.json` lookup and as the default parent of `models/`. */
|
|
18
|
+
export function resolveMlxNodeHome() {
|
|
19
|
+
return join(homedir(), '.mlx-node');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the directory where downloaded models live.
|
|
23
|
+
*
|
|
24
|
+
* Resolution order:
|
|
25
|
+
* 1. `explicit` arg (non-empty)
|
|
26
|
+
* 2. `MLX_MODELS_DIR` env var
|
|
27
|
+
* 3. `modelsDir` field in `$HOME/.mlx-node/config.json`
|
|
28
|
+
* 4. `$HOME/.mlx-node/models`
|
|
29
|
+
*
|
|
30
|
+
* Creates the chosen directory (recursive) before returning.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveModelsDir(explicit) {
|
|
33
|
+
if (explicit && explicit.length > 0) {
|
|
34
|
+
return ensureDir(resolve(explicit));
|
|
35
|
+
}
|
|
36
|
+
const envDir = process.env.MLX_MODELS_DIR;
|
|
37
|
+
if (envDir && envDir.length > 0) {
|
|
38
|
+
return ensureDir(resolve(envDir));
|
|
39
|
+
}
|
|
40
|
+
const configPath = join(resolveMlxNodeHome(), 'config.json');
|
|
41
|
+
const fromConfig = readModelsDirFromConfig(configPath);
|
|
42
|
+
if (fromConfig) {
|
|
43
|
+
return ensureDir(resolve(fromConfig));
|
|
44
|
+
}
|
|
45
|
+
return ensureDir(join(resolveMlxNodeHome(), 'models'));
|
|
46
|
+
}
|
|
47
|
+
function readModelsDirFromConfig(configPath) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Missing / unreadable file: fall through to default.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (typeof parsed.modelsDir === 'string' && parsed.modelsDir.length > 0) {
|
|
59
|
+
return parsed.modelsDir;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
console.warn(`[mlx] warning: malformed JSON in ${configPath}; falling back to default models dir`);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function ensureDir(path) {
|
|
69
|
+
mkdirSync(path, { recursive: true });
|
|
70
|
+
return path;
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-resident lazy-load policy for an inference host.
|
|
3
|
+
*
|
|
4
|
+
* The host discovers every local model up-front but loads at most one into
|
|
5
|
+
* the `ModelRegistry` at a time. Switching models (e.g. via Claude Code's
|
|
6
|
+
* `/model` picker, or the desktop app's model menu) unregisters the previous
|
|
7
|
+
* instance, letting GC + native destructors reclaim memory, before loading
|
|
8
|
+
* the new one.
|
|
9
|
+
*/
|
|
10
|
+
import type { LoadableModel } from '@mlx-node/lm';
|
|
11
|
+
import type { PublicModelEntry } from '../handler.js';
|
|
12
|
+
import type { ModelRegistry } from '../registry.js';
|
|
13
|
+
import type { DiscoveredModel } from './discover.js';
|
|
14
|
+
export interface SwapController {
|
|
15
|
+
resolveModel: (name: string) => Promise<void>;
|
|
16
|
+
listModels: () => PublicModelEntry[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build the `resolveModel` + `listModels` callbacks for the handler.
|
|
20
|
+
*
|
|
21
|
+
* `loadModelFn` is injected so tests can stub it without touching native code.
|
|
22
|
+
* The controller serializes every `resolveModel` invocation on a single
|
|
23
|
+
* promise chain so two concurrent requests for different-but-currently-
|
|
24
|
+
* unloaded models cannot race on the native compiled-path globals.
|
|
25
|
+
*/
|
|
26
|
+
export declare function makeSwapController(discovered: DiscoveredModel[], registry: ModelRegistry, loadModelFn: (path: string) => Promise<LoadableModel>, defaultName?: string): SwapController;
|
|
27
|
+
//# sourceMappingURL=swap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"swap.d.ts","sourceRoot":"","sources":["../../src/host/swap.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAuB,MAAM,cAAc,CAAC;AAEvE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,UAAU,EAAE,MAAM,gBAAgB,EAAE,CAAC;CACtC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,eAAe,EAAE,EAC7B,QAAQ,EAAE,aAAa,EACvB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,EACrD,WAAW,CAAC,EAAE,MAAM,GACnB,cAAc,CAqKhB"}
|