@mlx-node/server 0.0.7 → 0.0.9

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 (110) hide show
  1. package/dist/auth.d.ts +56 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +106 -0
  4. package/dist/chat-session-warm-reuse.d.ts +51 -0
  5. package/dist/chat-session-warm-reuse.d.ts.map +1 -0
  6. package/dist/chat-session-warm-reuse.js +72 -0
  7. package/dist/endpoints/messages-count-tokens.d.ts +8 -0
  8. package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
  9. package/dist/endpoints/messages-count-tokens.js +121 -0
  10. package/dist/endpoints/messages.d.ts +57 -5
  11. package/dist/endpoints/messages.d.ts.map +1 -1
  12. package/dist/endpoints/messages.js +1043 -147
  13. package/dist/endpoints/models.d.ts +2 -1
  14. package/dist/endpoints/models.d.ts.map +1 -1
  15. package/dist/endpoints/models.js +2 -2
  16. package/dist/endpoints/responses.d.ts +22 -7
  17. package/dist/endpoints/responses.d.ts.map +1 -1
  18. package/dist/endpoints/responses.js +608 -85
  19. package/dist/errors.d.ts +1 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -0
  22. package/dist/handler.d.ts +69 -1
  23. package/dist/handler.d.ts.map +1 -1
  24. package/dist/handler.js +70 -16
  25. package/dist/health.d.ts +146 -0
  26. package/dist/health.d.ts.map +1 -0
  27. package/dist/health.js +107 -0
  28. package/dist/host/discover.d.ts +19 -0
  29. package/dist/host/discover.d.ts.map +1 -0
  30. package/dist/host/discover.js +50 -0
  31. package/dist/host/env-policy.d.ts +62 -0
  32. package/dist/host/env-policy.d.ts.map +1 -0
  33. package/dist/host/env-policy.js +69 -0
  34. package/dist/host/index.d.ts +202 -0
  35. package/dist/host/index.d.ts.map +1 -0
  36. package/dist/host/index.js +325 -0
  37. package/dist/host/logger.d.ts +36 -0
  38. package/dist/host/logger.d.ts.map +1 -0
  39. package/dist/host/logger.js +376 -0
  40. package/dist/host/net.d.ts +65 -0
  41. package/dist/host/net.d.ts.map +1 -0
  42. package/dist/host/net.js +97 -0
  43. package/dist/host/paths.d.ts +28 -0
  44. package/dist/host/paths.d.ts.map +1 -0
  45. package/dist/host/paths.js +71 -0
  46. package/dist/host/swap.d.ts +27 -0
  47. package/dist/host/swap.d.ts.map +1 -0
  48. package/dist/host/swap.js +178 -0
  49. package/dist/host/temp-root.d.ts +57 -0
  50. package/dist/host/temp-root.d.ts.map +1 -0
  51. package/dist/host/temp-root.js +99 -0
  52. package/dist/idle-sweeper.d.ts +245 -0
  53. package/dist/idle-sweeper.d.ts.map +1 -0
  54. package/dist/idle-sweeper.js +408 -0
  55. package/dist/index.d.ts +19 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +17 -1
  58. package/dist/load-model.d.ts +69 -0
  59. package/dist/load-model.d.ts.map +1 -0
  60. package/dist/load-model.js +63 -0
  61. package/dist/mappers/anthropic-request.d.ts +24 -2
  62. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  63. package/dist/mappers/anthropic-request.js +222 -24
  64. package/dist/mappers/anthropic-response.d.ts +29 -4
  65. package/dist/mappers/anthropic-response.d.ts.map +1 -1
  66. package/dist/mappers/anthropic-response.js +143 -21
  67. package/dist/mappers/request.d.ts +48 -0
  68. package/dist/mappers/request.d.ts.map +1 -1
  69. package/dist/mappers/request.js +211 -35
  70. package/dist/mappers/response.d.ts.map +1 -1
  71. package/dist/mappers/response.js +13 -1
  72. package/dist/model-work-coordinator.d.ts +95 -0
  73. package/dist/model-work-coordinator.d.ts.map +1 -0
  74. package/dist/model-work-coordinator.js +201 -0
  75. package/dist/pending-writes.d.ts.map +1 -1
  76. package/dist/presets.d.ts +82 -0
  77. package/dist/presets.d.ts.map +1 -0
  78. package/dist/presets.js +98 -0
  79. package/dist/registry.d.ts +31 -1
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +33 -5
  82. package/dist/router.d.ts +37 -1
  83. package/dist/router.d.ts.map +1 -1
  84. package/dist/router.js +79 -7
  85. package/dist/server.d.ts +193 -3
  86. package/dist/server.d.ts.map +1 -1
  87. package/dist/server.js +173 -10
  88. package/dist/session-registry.d.ts +279 -18
  89. package/dist/session-registry.d.ts.map +1 -1
  90. package/dist/session-registry.js +518 -37
  91. package/dist/stop-sequence-buffer.d.ts +58 -0
  92. package/dist/stop-sequence-buffer.d.ts.map +1 -0
  93. package/dist/stop-sequence-buffer.js +148 -0
  94. package/dist/streaming.d.ts +14 -0
  95. package/dist/streaming.d.ts.map +1 -1
  96. package/dist/streaming.js +45 -0
  97. package/dist/text-recovery.d.ts +35 -0
  98. package/dist/text-recovery.d.ts.map +1 -0
  99. package/dist/text-recovery.js +41 -0
  100. package/dist/timing.d.ts +80 -0
  101. package/dist/timing.d.ts.map +1 -0
  102. package/dist/timing.js +121 -0
  103. package/dist/tool-call-buffer.d.ts +5 -5
  104. package/dist/tool-call-buffer.d.ts.map +1 -1
  105. package/dist/tool-call-buffer.js +28 -8
  106. package/dist/types-anthropic.d.ts +161 -1
  107. package/dist/types-anthropic.d.ts.map +1 -1
  108. package/dist/types.d.ts +172 -2
  109. package/dist/types.d.ts.map +1 -1
  110. 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"}
@@ -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"}