@mlx-node/server 0.0.13 → 0.0.15

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