@mlx-node/server 0.0.8 → 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 (61) 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 +8 -8
  5. package/dist/chat-session-warm-reuse.d.ts.map +1 -1
  6. package/dist/chat-session-warm-reuse.js +12 -8
  7. package/dist/endpoints/responses.d.ts +3 -1
  8. package/dist/endpoints/responses.d.ts.map +1 -1
  9. package/dist/endpoints/responses.js +38 -5
  10. package/dist/handler.d.ts +27 -1
  11. package/dist/handler.d.ts.map +1 -1
  12. package/dist/handler.js +65 -16
  13. package/dist/health.d.ts +146 -0
  14. package/dist/health.d.ts.map +1 -0
  15. package/dist/health.js +107 -0
  16. package/dist/host/discover.d.ts +19 -0
  17. package/dist/host/discover.d.ts.map +1 -0
  18. package/dist/host/discover.js +50 -0
  19. package/dist/host/env-policy.d.ts +62 -0
  20. package/dist/host/env-policy.d.ts.map +1 -0
  21. package/dist/host/env-policy.js +69 -0
  22. package/dist/host/index.d.ts +202 -0
  23. package/dist/host/index.d.ts.map +1 -0
  24. package/dist/host/index.js +325 -0
  25. package/dist/host/logger.d.ts +36 -0
  26. package/dist/host/logger.d.ts.map +1 -0
  27. package/dist/host/logger.js +376 -0
  28. package/dist/host/net.d.ts +65 -0
  29. package/dist/host/net.d.ts.map +1 -0
  30. package/dist/host/net.js +97 -0
  31. package/dist/host/paths.d.ts +28 -0
  32. package/dist/host/paths.d.ts.map +1 -0
  33. package/dist/host/paths.js +71 -0
  34. package/dist/host/swap.d.ts +27 -0
  35. package/dist/host/swap.d.ts.map +1 -0
  36. package/dist/host/swap.js +178 -0
  37. package/dist/host/temp-root.d.ts +57 -0
  38. package/dist/host/temp-root.d.ts.map +1 -0
  39. package/dist/host/temp-root.js +99 -0
  40. package/dist/index.d.ts +11 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +7 -1
  43. package/dist/load-model.d.ts +69 -0
  44. package/dist/load-model.d.ts.map +1 -0
  45. package/dist/load-model.js +63 -0
  46. package/dist/model-work-coordinator.d.ts +29 -4
  47. package/dist/model-work-coordinator.d.ts.map +1 -1
  48. package/dist/model-work-coordinator.js +97 -16
  49. package/dist/router.d.ts +34 -1
  50. package/dist/router.d.ts.map +1 -1
  51. package/dist/router.js +47 -5
  52. package/dist/server.d.ts +117 -3
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +125 -9
  55. package/dist/session-registry.d.ts +7 -0
  56. package/dist/session-registry.d.ts.map +1 -1
  57. package/dist/session-registry.js +9 -0
  58. package/dist/streaming.d.ts +14 -0
  59. package/dist/streaming.d.ts.map +1 -1
  60. package/dist/streaming.js +45 -0
  61. package/package.json +15 -3
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `@mlx-node/server/host` — the reusable inference-host bootstrap.
3
+ *
4
+ * Everything a process needs to go from "a directory of downloaded models" to
5
+ * "a listening Anthropic/OpenAI-compatible endpoint with one resident model
6
+ * and a working `/model` swap", with none of the opinions about WHO supervises
7
+ * the process.
8
+ *
9
+ * That last part is why this is a module rather than a CLI flag. The two known
10
+ * front-ends invert the supervision relationship:
11
+ *
12
+ * - `mlx launch claude` starts the host, then spawns and supervises a child
13
+ * (`claude`), and exits when the child does.
14
+ * - The desktop app's Electron `utilityProcess` IS the child; the host is
15
+ * supervised, gets told when to shut down, and may be SIGKILLed.
16
+ *
17
+ * `mlx serve` is the third, degenerate case — nothing above, nothing below —
18
+ * and doubles as the terminal-visible reproduction of the sidecar when the
19
+ * utilityProcess wedges.
20
+ *
21
+ * A subpath export rather than part of `@mlx-node/server`'s main entry, so
22
+ * importing the plain HTTP handler does not drag in `@mlx-node/lm` and model
23
+ * loading.
24
+ *
25
+ * ## Ownership
26
+ *
27
+ * The returned host owns, and disposes on {@link InferenceHost.close}:
28
+ * 1. the HTTP server,
29
+ * 2. the verbose request logger (when `logDir` is set),
30
+ * 3. the `PagedConfigOverrideManager`'s temp root.
31
+ *
32
+ * The server goes first because the logger records a request from that
33
+ * request's own `finish`/`close` handler. Ending the log streams while a
34
+ * request is still draining loses exactly the completion lines a verbose
35
+ * shutdown exists to capture. The temp root goes last because a
36
+ * still-draining request may still be reading a cloned config. Each step is
37
+ * independently guarded — one failing disposer must not strand the ones
38
+ * after it.
39
+ */
40
+ import { loadModel as loadModelNative, PagedConfigOverrideManager } from '@mlx-node/lm';
41
+ import { createServer, resolveAuthToken } from '../server.js';
42
+ import { discoverModels } from './discover.js';
43
+ import { applyEnginePolicy } from './env-policy.js';
44
+ import { attachLogger as defaultAttachLogger } from './logger.js';
45
+ import { hostUrl, isLoopbackBindHost, normalizeLoopbackBindHost, pickFreePort } from './net.js';
46
+ import { resolveModelsDir } from './paths.js';
47
+ import { makeSwapController } from './swap.js';
48
+ import { hostTempDirPrefix, sweepOrphanHostTempRoots } from './temp-root.js';
49
+ /** Families `mlx launch claude` has historically forced onto the paged path. */
50
+ export const DEFAULT_PAGED_MODEL_TYPES = ['qwen3_5', 'qwen3_5_moe'];
51
+ /** Thrown when `modelsDir` holds nothing servable. Carries the dir for the caller's message. */
52
+ export class NoModelsDiscoveredError extends Error {
53
+ modelsDir;
54
+ constructor(modelsDir) {
55
+ super(`No models discovered under ${modelsDir}.`);
56
+ this.modelsDir = modelsDir;
57
+ this.name = 'NoModelsDiscoveredError';
58
+ }
59
+ }
60
+ /**
61
+ * Thrown when a bind reachable from the network was asked for with no shared
62
+ * secret to gate it.
63
+ *
64
+ * There is no safe way to serve that: every route but the `/health` liveness
65
+ * carve-out runs inference, so an unauthenticated LAN-reachable bind hands
66
+ * anyone who can route to this machine the GPU, the RAM, and the list of
67
+ * models on disk. Failing at startup is the only outcome the operator can act
68
+ * on — serving-with-a-warning is a warning nobody reads scrolling past a
69
+ * model load.
70
+ */
71
+ export class InsecureBindError extends Error {
72
+ host;
73
+ constructor(host) {
74
+ super(`Refusing to bind ${host} without an auth token: every route except /health runs inference. ` +
75
+ `Pass an auth token (mlx serve --auth-token, or MLX_SERVER_AUTH_TOKEN), or bind 127.0.0.1.`);
76
+ this.host = host;
77
+ this.name = 'InsecureBindError';
78
+ }
79
+ }
80
+ /** Thrown when a requested model name is not among the discovered ones. */
81
+ export class ModelNotFoundError extends Error {
82
+ requested;
83
+ modelsDir;
84
+ available;
85
+ constructor(requested, modelsDir, available) {
86
+ super(`Model "${requested}" not found under ${modelsDir}.`);
87
+ this.requested = requested;
88
+ this.modelsDir = modelsDir;
89
+ this.available = available;
90
+ this.name = 'ModelNotFoundError';
91
+ }
92
+ }
93
+ /** Thrown when a model load is requested after host shutdown reaches its admission boundary. */
94
+ export class InferenceHostClosedError extends Error {
95
+ constructor() {
96
+ super('Inference host is closing or closed; model loads are no longer accepted.');
97
+ this.name = 'InferenceHostClosedError';
98
+ }
99
+ }
100
+ /**
101
+ * Start a local inference host.
102
+ *
103
+ * Throws {@link NoModelsDiscoveredError} / {@link ModelNotFoundError} /
104
+ * {@link InsecureBindError} rather than exiting — a library cannot know
105
+ * whether its caller is a CLI, a test, or an Electron main process. Front-ends
106
+ * render those into their own messages.
107
+ */
108
+ export async function createInferenceHost(opts = {}) {
109
+ const host = opts.host ?? '127.0.0.1';
110
+ // BEFORE the engine policy, the temp sweep and discovery — all of which
111
+ // mutate state outside this function — so a refused start leaves nothing
112
+ // behind. Resolved through `resolveAuthToken` rather than reading
113
+ // `opts.authToken`, or `MLX_SERVER_AUTH_TOKEN=… mlx serve --host 0.0.0.0`
114
+ // would be refused despite being fully protected.
115
+ if (!isLoopbackBindHost(host) && resolveAuthToken(opts.authToken) === undefined) {
116
+ throw new InsecureBindError(host);
117
+ }
118
+ // `[::1]` is valid URL-authority spelling and the security predicate accepts
119
+ // it as loopback, but Node's listen host must be the bare literal. Normalize
120
+ // only after the reachability gate, and only the exact safe form.
121
+ const bindHost = normalizeLoopbackBindHost(host);
122
+ // FIRST, before anything can touch the engine: the native side latches
123
+ // these via `OnceLock` on first read, so a policy applied after a load has
124
+ // silently done nothing.
125
+ if (opts.enginePolicy !== undefined)
126
+ applyEnginePolicy(opts.enginePolicy);
127
+ // Reclaim roots left by hosts that never got to run `close()`. Best effort
128
+ // and awaited only so a test can observe it; a failure here is not a reason
129
+ // to refuse to start.
130
+ if (opts.sweepOrphanTempRoots !== false) {
131
+ await sweepOrphanHostTempRoots().catch(() => []);
132
+ }
133
+ const modelsDir = resolveModelsDir(opts.modelsDir);
134
+ const models = await discoverModels(modelsDir);
135
+ if (models.length === 0)
136
+ throw new NoModelsDiscoveredError(modelsDir);
137
+ // Precedence: explicit option > ANTHROPIC_MODEL > discovered[0].
138
+ const requestedModel = opts.model ?? process.env.ANTHROPIC_MODEL;
139
+ const requestedEntry = requestedModel != null ? models.find((m) => m.name === requestedModel) : undefined;
140
+ if (requestedModel != null && requestedEntry === undefined) {
141
+ throw new ModelNotFoundError(requestedModel, modelsDir, models.map((m) => m.name));
142
+ }
143
+ const boundEntry = requestedEntry ?? models[0];
144
+ // `undefined` means "you pick"; `0` means "the kernel picks and I will read
145
+ // it back". Only the former needs the up-front probe.
146
+ const requestedPort = opts.port ?? (await pickFreePort());
147
+ // The swap controller needs the registry from the server instance, but the
148
+ // server needs the controller's callbacks at construction. Bridge via a
149
+ // late-bound holder: the callbacks capture `ctrlRef.current` by closure.
150
+ const ctrlRef = { current: null };
151
+ let acceptingHttpModelLoads = true;
152
+ const activeHttpModelLoads = new Set();
153
+ const resolveHttpModel = (name) => {
154
+ if (!acceptingHttpModelLoads)
155
+ return Promise.reject(new InferenceHostClosedError());
156
+ const operation = ctrlRef.current.resolveModel(name);
157
+ activeHttpModelLoads.add(operation);
158
+ void operation.then(() => activeHttpModelLoads.delete(operation), () => activeHttpModelLoads.delete(operation));
159
+ return operation;
160
+ };
161
+ const serverConfig = {
162
+ port: requestedPort,
163
+ host: bindHost,
164
+ resolveModel: resolveHttpModel,
165
+ listModels: () => ctrlRef.current.listModels(),
166
+ ...(opts.authToken !== undefined ? { authToken: opts.authToken } : {}),
167
+ ...(opts.storePath !== undefined ? { storePath: opts.storePath } : {}),
168
+ ...(opts.disableStore !== undefined ? { disableStore: opts.disableStore } : {}),
169
+ };
170
+ const server = await createServer(serverConfig);
171
+ // Wrap the loader so managed families get `use_block_paged_cache: true`
172
+ // injected via a temp-dir clone with a patched config.json. The temp root is
173
+ // named after our pid so a SIGKILLed host's root can be reclaimed by the
174
+ // next host's startup sweep — see `temp-root.ts`.
175
+ const pagedConfigOverrides = new PagedConfigOverrideManager({
176
+ modelTypes: opts.pagedModelTypes ?? DEFAULT_PAGED_MODEL_TYPES,
177
+ tempDirPrefix: hostTempDirPrefix(),
178
+ });
179
+ const loadModelFn = opts.loadModel ?? loadModelNative;
180
+ const loadModelPagedAware = async (path) => loadModelFn(await pagedConfigOverrides.resolve(path));
181
+ const controller = makeSwapController(models, server.registry, loadModelPagedAware, boundEntry.name);
182
+ ctrlRef.current = controller;
183
+ // Attach AFTER `createServer` so the wrapper sees every incoming request,
184
+ // including the `GET /v1/models` a client fires on startup.
185
+ //
186
+ // By this point the socket is bound and `ctrlRef.current` is wired, so the
187
+ // endpoint already answers real requests. `attachLogger` opens with a
188
+ // synchronous `mkdirSync`, which throws on an unwritable `--log-dir`; without
189
+ // this rollback the rejection would strand a fully working inference server
190
+ // with no handle left to close it. Every caller in this repo exits the
191
+ // process on failure, so the leak is only reachable by an in-process
192
+ // embedder — which is exactly who this module is for.
193
+ let logger = null;
194
+ if (opts.logDir !== undefined) {
195
+ try {
196
+ logger = (opts.attachLogger ?? defaultAttachLogger)(server.server, opts.logDir);
197
+ }
198
+ catch (err) {
199
+ // Mirror `close()`'s order and its independent guards, and rethrow the
200
+ // ORIGINAL failure — a secondary close error must not mask the EACCES
201
+ // that actually explains what went wrong.
202
+ try {
203
+ await server.close();
204
+ }
205
+ catch {
206
+ /* already down, or a socket refused to die; fall through */
207
+ }
208
+ // A forced close destroys sockets, not the async handler promises
209
+ // `http.createServer` discarded. Close resolver admission only after
210
+ // the server has finished its graceful window, then drain any lazy load
211
+ // that was already inside the controller before removing its temp files.
212
+ acceptingHttpModelLoads = false;
213
+ await Promise.allSettled(activeHttpModelLoads);
214
+ try {
215
+ await pagedConfigOverrides.cleanup();
216
+ }
217
+ catch {
218
+ /* the startup sweep of the next host will reclaim it */
219
+ }
220
+ throw err;
221
+ }
222
+ }
223
+ const address = server.server.address();
224
+ const boundPort = address !== null && typeof address === 'object' ? address.port : requestedPort;
225
+ const byName = new Map(models.map((m) => [m.name, m]));
226
+ // `closeStarted` is an admission latch, checked and flipped synchronously:
227
+ // once close() returns its promise no later out-of-band load can join this
228
+ // set. The operations themselves retain the normal coordinator/controller
229
+ // ordering; shutdown only observes their promises and never takes a lock
230
+ // they need, so queued swaps can drain without deadlocking against close.
231
+ let closeStarted = false;
232
+ const activeHostLoads = new Set();
233
+ let closePromise = null;
234
+ return {
235
+ url: hostUrl(host, boundPort),
236
+ port: boundPort,
237
+ host,
238
+ modelsDir,
239
+ models,
240
+ boundModel: boundEntry.name,
241
+ logDir: logger?.logDir ?? null,
242
+ server,
243
+ health: () => server.health(),
244
+ loadModel(name) {
245
+ if (closeStarted)
246
+ return Promise.reject(new InferenceHostClosedError());
247
+ if (!byName.has(name)) {
248
+ return Promise.reject(new ModelNotFoundError(name, modelsDir, models.map((m) => m.name)));
249
+ }
250
+ // Same nesting as `runGuardedModelLoad`: the drain suspension must be
251
+ // OUTSIDE the writer lock so the armed `clearCache()` timer cannot fire
252
+ // while we are parked waiting for the lock. The HTTP path takes the
253
+ // locks in this same order, so the two can never deadlock against each
254
+ // other on the controller's internal serialization.
255
+ const operation = server.withSuspendedDrains(async () => {
256
+ await server.modelWork.withModelLoad(() => controller.resolveModel(name), name);
257
+ });
258
+ activeHostLoads.add(operation);
259
+ // Use a two-arm `then`, rather than an ignored `finally()` promise:
260
+ // cleanup must not manufacture an unhandled rejection when the caller
261
+ // legitimately observes a failed load through `operation`.
262
+ void operation.then(() => activeHostLoads.delete(operation), () => activeHostLoads.delete(operation));
263
+ return operation;
264
+ },
265
+ close(closeOpts) {
266
+ if (closePromise !== null)
267
+ return closePromise;
268
+ // Flip the latch and snapshot in the same synchronous turn. Every call
269
+ // in the snapshot was fully admitted before shutdown; no later call can
270
+ // race into the set after this point.
271
+ closeStarted = true;
272
+ const admittedHostLoads = [...activeHostLoads];
273
+ closePromise = (async () => {
274
+ // Every step is independently guarded: a server that fails to close
275
+ // must still leave the log streams ended and the temp root
276
+ // reclaimed. The temp root is the only one of the three that leaks
277
+ // OUTSIDE this process.
278
+ try {
279
+ await server.close(closeOpts);
280
+ }
281
+ catch {
282
+ /* already down, or a socket refused to die; fall through */
283
+ }
284
+ // A forced server close destroys request sockets but does not await
285
+ // the async handler promises. Stop any handler that has not reached
286
+ // the resolver from starting a late load, then snapshot resolver calls
287
+ // already inside the controller. Direct calls were closed and
288
+ // snapshotted synchronously above; both groups can now drain without
289
+ // shutdown holding a coordinator/controller lock they need.
290
+ acceptingHttpModelLoads = false;
291
+ const admittedHttpModelLoads = [...activeHttpModelLoads];
292
+ // `allSettled` preserves cleanup when either kind of loader rejects;
293
+ // the original operation still carries that error to its caller.
294
+ await Promise.allSettled([...admittedHostLoads, ...admittedHttpModelLoads]);
295
+ // Only once nothing is still serving. The logger writes a request's
296
+ // record from that request's `finish` handler, so ending the streams
297
+ // first both drops the record and — with no `error` listener — makes
298
+ // the write fatal. `attachLogger` guards the second half; this
299
+ // ordering is what preserves the first.
300
+ if (logger !== null) {
301
+ try {
302
+ await logger.close();
303
+ }
304
+ catch {
305
+ /* logging is best effort; never block shutdown */
306
+ }
307
+ }
308
+ try {
309
+ await pagedConfigOverrides.cleanup();
310
+ }
311
+ catch {
312
+ /* the startup sweep of the next host will reclaim it */
313
+ }
314
+ })();
315
+ return closePromise;
316
+ },
317
+ };
318
+ }
319
+ export { discoverModels } from './discover.js';
320
+ export { applyEnginePolicy, engineEnvFor, ENGINE_POLICY_ENV_VARS, LAUNCHER_ENGINE_POLICY, } from './env-policy.js';
321
+ export { attachLogger, resolveLogDir } from './logger.js';
322
+ export { bracketHost, hostUrl, isLoopbackBindHost, pickFreePort } from './net.js';
323
+ export { resolveMlxNodeHome, resolveModelsDir } from './paths.js';
324
+ export { makeSwapController } from './swap.js';
325
+ export { hostTempDirPrefix, isProcessAlive, sweepOrphanHostTempRoots, HOST_TEMP_DIR_STEM, } from './temp-root.js';
@@ -0,0 +1,36 @@
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 type { Server } from 'node:http';
14
+ export interface Logger {
15
+ /** Absolute log directory in use. */
16
+ readonly logDir: string;
17
+ /** Flush and close the underlying streams. Safe to call multiple times. */
18
+ close(): Promise<void>;
19
+ }
20
+ /**
21
+ * Attach request/response capture to `server`. Call `close()` AFTER
22
+ * `server.close()` resolves: the completion listeners below fire when a
23
+ * response finishes, so ending the streams first drops the tail of every
24
+ * request still in flight.
25
+ */
26
+ export declare function attachLogger(server: Server, logDir: string): Logger;
27
+ /**
28
+ * Resolve the log directory for a verbose launch.
29
+ *
30
+ * Order: explicit `--log-dir` > `MLX_LOG_DIR` env > a fresh timestamped
31
+ * directory under `<mlxNodeHome>/logs/`. The timestamped default gives
32
+ * each launch its own dir so concurrent / sequential runs don't
33
+ * interleave into one file.
34
+ */
35
+ export declare function resolveLogDir(explicit: string | undefined, mlxNodeHome: string): string;
36
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/host/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAmB,MAAM,EAAkB,MAAM,WAAW,CAAC;AAGzE,MAAM,WAAW,MAAM;IACrB,qCAAqC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA+MD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAsKnE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAMvF"}