@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,496 @@
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
+
41
+ import type { Server } from 'node:http';
42
+
43
+ import {
44
+ loadModel as loadModelNative,
45
+ PagedConfigOverrideManager,
46
+ QWEN35_PAGED_MODEL_TYPES,
47
+ type LoadableModel,
48
+ type LoadModelOptions,
49
+ } from '@mlx-node/lm';
50
+ import { findDFlash2Draft } from '@mlx-node/lm/draft-companion';
51
+
52
+ import type { ServerHealth } from '../health.js';
53
+ import { createServer, resolveAuthToken, type CloseOptions, type ServerInstance } from '../server.js';
54
+ import { discoverModels, type DiscoveredModel } from './discover.js';
55
+ import { applyEnginePolicy, type EnginePolicy } from './env-policy.js';
56
+ import { attachLogger as defaultAttachLogger, type Logger } from './logger.js';
57
+ import { hostUrl, isLoopbackBindHost, normalizeLoopbackBindHost, pickFreePort } from './net.js';
58
+ import { resolveModelsDir } from './paths.js';
59
+ import { makeSwapController } from './swap.js';
60
+ import { hostTempDirPrefix, sweepOrphanHostTempRoots } from './temp-root.js';
61
+
62
+ /** Families `mlx launch claude` has historically forced onto the paged path. */
63
+ export const DEFAULT_PAGED_MODEL_TYPES = QWEN35_PAGED_MODEL_TYPES;
64
+
65
+ /** Thrown when `modelsDir` holds nothing servable. Carries the dir for the caller's message. */
66
+ export class NoModelsDiscoveredError extends Error {
67
+ constructor(readonly modelsDir: string) {
68
+ super(`No models discovered under ${modelsDir}.`);
69
+ this.name = 'NoModelsDiscoveredError';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Thrown when a bind reachable from the network was asked for with no shared
75
+ * secret to gate it.
76
+ *
77
+ * There is no safe way to serve that: every route but the `/health` liveness
78
+ * carve-out runs inference, so an unauthenticated LAN-reachable bind hands
79
+ * anyone who can route to this machine the GPU, the RAM, and the list of
80
+ * models on disk. Failing at startup is the only outcome the operator can act
81
+ * on — serving-with-a-warning is a warning nobody reads scrolling past a
82
+ * model load.
83
+ */
84
+ export class InsecureBindError extends Error {
85
+ constructor(readonly host: string) {
86
+ super(
87
+ `Refusing to bind ${host} without an auth token: every route except /health runs inference. ` +
88
+ `Pass an auth token (mlx serve --auth-token, or MLX_SERVER_AUTH_TOKEN), or bind 127.0.0.1.`,
89
+ );
90
+ this.name = 'InsecureBindError';
91
+ }
92
+ }
93
+
94
+ /** Thrown when a requested model name is not among the discovered ones. */
95
+ export class ModelNotFoundError extends Error {
96
+ constructor(
97
+ readonly requested: string,
98
+ readonly modelsDir: string,
99
+ readonly available: string[],
100
+ ) {
101
+ super(`Model "${requested}" not found under ${modelsDir}.`);
102
+ this.name = 'ModelNotFoundError';
103
+ }
104
+ }
105
+
106
+ /** Thrown when a model load is requested after host shutdown reaches its admission boundary. */
107
+ export class InferenceHostClosedError extends Error {
108
+ constructor() {
109
+ super('Inference host is closing or closed; model loads are no longer accepted.');
110
+ this.name = 'InferenceHostClosedError';
111
+ }
112
+ }
113
+
114
+ export interface InferenceHostOptions {
115
+ /**
116
+ * Port to bind. Omitted ⇒ a free port is picked up-front (so the URL is
117
+ * known before the server starts, which `mlx launch claude` needs in order
118
+ * to bake `ANTHROPIC_BASE_URL` into the child's env). `0` binds an
119
+ * ephemeral port and the real one is read back off the socket.
120
+ */
121
+ port?: number;
122
+ /**
123
+ * Host to bind. Default `127.0.0.1`.
124
+ *
125
+ * A non-loopback value (including the wildcards `0.0.0.0` / `::`) requires
126
+ * an auth token — from {@link InferenceHostOptions.authToken} or
127
+ * `MLX_SERVER_AUTH_TOKEN` — or the call throws {@link InsecureBindError}
128
+ * instead of listening.
129
+ */
130
+ host?: string;
131
+ /** Model discovery root. Default: {@link resolveModelsDir}'s resolution order. */
132
+ modelsDir?: string;
133
+ /**
134
+ * Which discovered model is the bound/default one. Precedence, highest
135
+ * first: this option, `ANTHROPIC_MODEL`, `discovered[0]` (alphabetical).
136
+ * A name that matches nothing discovered throws {@link ModelNotFoundError}
137
+ * rather than silently falling back.
138
+ */
139
+ model?: string;
140
+ /** Shared secret for every route except `/health`. See `ServerConfig.authToken`. */
141
+ authToken?: string;
142
+ /** When set, every HTTP turn is captured under this directory. */
143
+ logDir?: string;
144
+ /**
145
+ * Engine env policy to apply to `process.env` BEFORE anything can load a
146
+ * model. Omitted ⇒ nothing is written and the engine's own defaults stand.
147
+ * Out-of-process hosts must pass `engineEnvFor(policy)` to `fork({ env })`
148
+ * instead and leave this unset — the vars latch via `OnceLock` on first
149
+ * read, so mutating them in the child is too late.
150
+ */
151
+ enginePolicy?: EnginePolicy;
152
+ /** Model types forced onto the block-paged KV cache. Default {@link DEFAULT_PAGED_MODEL_TYPES}. */
153
+ pagedModelTypes?: readonly string[];
154
+ /** Path to the SQLite response store. See `ServerConfig.storePath`. */
155
+ storePath?: string;
156
+ /** Disable response persistence entirely. See `ServerConfig.disableStore`. */
157
+ disableStore?: boolean;
158
+ /**
159
+ * Per-model waiter cap forwarded to `createServer`. Default 16; pass
160
+ * `'unbounded'` for an explicitly uncapped embedded host.
161
+ */
162
+ maxQueueDepthPerModel?: number | 'unbounded';
163
+ /**
164
+ * Remove temp roots left behind by hosts that were killed without running
165
+ * `close()`. Default `true`; a supervisor that runs several hosts under one
166
+ * pid namespace and wants to control the timing can turn it off and call
167
+ * {@link sweepOrphanHostTempRoots} itself.
168
+ */
169
+ sweepOrphanTempRoots?: boolean;
170
+
171
+ /** Test seam: replaces the native model loader. */
172
+ loadModel?: (path: string, options?: LoadModelOptions) => Promise<LoadableModel>;
173
+ /** Test seam: replaces the verbose request logger. */
174
+ attachLogger?: (server: Server, logDir: string) => Logger;
175
+ }
176
+
177
+ export interface InferenceHost {
178
+ /** Connectable base URL, e.g. `http://127.0.0.1:51234`. */
179
+ url: string;
180
+ /** The port actually bound (resolved, never `0`). */
181
+ port: number;
182
+ /** The host actually bound — the requested value, not the advertised one. */
183
+ host: string;
184
+ /** The resolved model discovery root. */
185
+ modelsDir: string;
186
+ /** Everything discovered under `modelsDir`, alphabetical. */
187
+ models: DiscoveredModel[];
188
+ /** Name of the default/bound model. Always a member of {@link models}. */
189
+ boundModel: string;
190
+ /** Verbose log directory, or `null` when logging is off. */
191
+ logDir: string | null;
192
+ /** Escape hatch for callers that need the registry, store, or raw `http.Server`. */
193
+ server: ServerInstance;
194
+ /** Current readiness snapshot — the same body `GET /health` returns. */
195
+ health(): ServerHealth;
196
+ /**
197
+ * Make `name` the resident model, out of band (no HTTP request needed).
198
+ *
199
+ * Runs under the SAME brackets `ServerInstance.loadModel` uses — drains
200
+ * suspended OUTSIDE the coordinator's exclusive writer slot — so it cannot
201
+ * race the process-wide Metal allocator against in-flight inference, and
202
+ * `/health` reports `loading` plus a `lastLoad` record labelled with `name`.
203
+ *
204
+ * It deliberately does NOT call `ServerInstance.loadModel` itself. That
205
+ * helper only ever registers, never unregisters, so calling it per swap
206
+ * would accumulate every model the user has ever picked in memory. The
207
+ * single-resident swap controller is the thing that makes a swap a swap;
208
+ * this method just wraps it in the right brackets.
209
+ *
210
+ * Rejects with {@link ModelNotFoundError} for an unknown name — the swap
211
+ * controller would otherwise treat it as an alias for the current resident,
212
+ * which is right for Claude Code's hardcoded `claude-haiku-*` but wrong for
213
+ * a supervisor that asked for a specific model.
214
+ *
215
+ * Calls admitted before {@link close} begins are allowed to finish. Calls
216
+ * made after shutdown begins reject with {@link InferenceHostClosedError}.
217
+ */
218
+ loadModel(name: string): Promise<void>;
219
+ /**
220
+ * Stop accepting new HTTP and out-of-band load work, wait for every
221
+ * out-of-band load already admitted, then dispose the logger and temp root.
222
+ * Idempotent and memoized; disposal steps are individually guarded so one
223
+ * failure cannot strand the remaining steps.
224
+ */
225
+ close(opts?: CloseOptions): Promise<void>;
226
+ }
227
+
228
+ /**
229
+ * Start a local inference host.
230
+ *
231
+ * Throws {@link NoModelsDiscoveredError} / {@link ModelNotFoundError} /
232
+ * {@link InsecureBindError} rather than exiting — a library cannot know
233
+ * whether its caller is a CLI, a test, or an Electron main process. Front-ends
234
+ * render those into their own messages.
235
+ */
236
+ export async function createInferenceHost(opts: InferenceHostOptions = {}): Promise<InferenceHost> {
237
+ const host = opts.host ?? '127.0.0.1';
238
+ // BEFORE the engine policy, the temp sweep and discovery — all of which
239
+ // mutate state outside this function — so a refused start leaves nothing
240
+ // behind. Resolved through `resolveAuthToken` rather than reading
241
+ // `opts.authToken`, or `MLX_SERVER_AUTH_TOKEN=… mlx serve --host 0.0.0.0`
242
+ // would be refused despite being fully protected.
243
+ if (!isLoopbackBindHost(host) && resolveAuthToken(opts.authToken) === undefined) {
244
+ throw new InsecureBindError(host);
245
+ }
246
+ // `[::1]` is valid URL-authority spelling and the security predicate accepts
247
+ // it as loopback, but Node's listen host must be the bare literal. Normalize
248
+ // only after the reachability gate, and only the exact safe form.
249
+ const bindHost = normalizeLoopbackBindHost(host);
250
+
251
+ // FIRST, before anything can touch the engine: the native side latches
252
+ // these via `OnceLock` on first read, so a policy applied after a load has
253
+ // silently done nothing.
254
+ if (opts.enginePolicy !== undefined) applyEnginePolicy(opts.enginePolicy);
255
+
256
+ // Reclaim roots left by hosts that never got to run `close()`. Best effort
257
+ // and awaited only so a test can observe it; a failure here is not a reason
258
+ // to refuse to start.
259
+ if (opts.sweepOrphanTempRoots !== false) {
260
+ await sweepOrphanHostTempRoots().catch(() => []);
261
+ }
262
+
263
+ const modelsDir = resolveModelsDir(opts.modelsDir);
264
+ const models = await discoverModels(modelsDir);
265
+ if (models.length === 0) throw new NoModelsDiscoveredError(modelsDir);
266
+
267
+ // Precedence: explicit option > ANTHROPIC_MODEL > discovered[0].
268
+ const requestedModel = opts.model ?? process.env.ANTHROPIC_MODEL;
269
+ const requestedEntry = requestedModel != null ? models.find((m) => m.name === requestedModel) : undefined;
270
+ if (requestedModel != null && requestedEntry === undefined) {
271
+ throw new ModelNotFoundError(
272
+ requestedModel,
273
+ modelsDir,
274
+ models.map((m) => m.name),
275
+ );
276
+ }
277
+ const boundEntry = requestedEntry ?? models[0];
278
+
279
+ // `undefined` means "you pick"; `0` means "the kernel picks and I will read
280
+ // it back". Only the former needs the up-front probe.
281
+ const requestedPort = opts.port ?? (await pickFreePort());
282
+
283
+ // The swap controller needs the registry from the server instance, but the
284
+ // server needs the controller's callbacks at construction. Bridge via a
285
+ // late-bound holder: the callbacks capture `ctrlRef.current` by closure.
286
+ const ctrlRef: { current: ReturnType<typeof makeSwapController> | null } = { current: null };
287
+ let acceptingHttpModelLoads = true;
288
+ const activeHttpModelLoads = new Set<Promise<void>>();
289
+
290
+ const resolveHttpModel = (name: string): Promise<void> => {
291
+ if (!acceptingHttpModelLoads) return Promise.reject(new InferenceHostClosedError());
292
+ const operation = ctrlRef.current!.resolveModel(name);
293
+ activeHttpModelLoads.add(operation);
294
+ void operation.then(
295
+ () => activeHttpModelLoads.delete(operation),
296
+ () => activeHttpModelLoads.delete(operation),
297
+ );
298
+ return operation;
299
+ };
300
+
301
+ const serverConfig = {
302
+ port: requestedPort,
303
+ host: bindHost,
304
+ resolveModel: resolveHttpModel,
305
+ listModels: () => ctrlRef.current!.listModels(),
306
+ ...(opts.authToken !== undefined ? { authToken: opts.authToken } : {}),
307
+ ...(opts.storePath !== undefined ? { storePath: opts.storePath } : {}),
308
+ ...(opts.disableStore !== undefined ? { disableStore: opts.disableStore } : {}),
309
+ ...(opts.maxQueueDepthPerModel !== undefined ? { maxQueueDepthPerModel: opts.maxQueueDepthPerModel } : {}),
310
+ };
311
+ const server = await createServer(serverConfig);
312
+
313
+ // Wrap the loader so managed families get `use_block_paged_cache: true`
314
+ // injected via a temp-dir clone with a patched config.json. The temp root is
315
+ // named after our pid so a SIGKILLed host's root can be reclaimed by the
316
+ // next host's startup sweep — see `temp-root.ts`.
317
+ const pagedConfigOverrides = new PagedConfigOverrideManager({
318
+ modelTypes: opts.pagedModelTypes ?? DEFAULT_PAGED_MODEL_TYPES,
319
+ tempDirPrefix: hostTempDirPrefix(),
320
+ });
321
+ const loadModelFn = opts.loadModel ?? loadModelNative;
322
+ const loadModelPagedAware = async (path: string): Promise<LoadableModel> => {
323
+ const entry = models.find((model) => model.path === path)!;
324
+ // Resolve against the source directory before creating the paged config overlay.
325
+ const draftModelPath = findDFlash2Draft(path, entry.modelType, modelsDir);
326
+ const resolvedPath = await pagedConfigOverrides.resolve(path);
327
+ return draftModelPath === undefined ? loadModelFn(resolvedPath) : loadModelFn(resolvedPath, { draftModelPath });
328
+ };
329
+ const controller = makeSwapController(models, server.registry, loadModelPagedAware, boundEntry.name);
330
+ ctrlRef.current = controller;
331
+
332
+ // Attach AFTER `createServer` so the wrapper sees every incoming request,
333
+ // including the `GET /v1/models` a client fires on startup.
334
+ //
335
+ // By this point the socket is bound and `ctrlRef.current` is wired, so the
336
+ // endpoint already answers real requests. `attachLogger` opens with a
337
+ // synchronous `mkdirSync`, which throws on an unwritable `--log-dir`; without
338
+ // this rollback the rejection would strand a fully working inference server
339
+ // with no handle left to close it. Every caller in this repo exits the
340
+ // process on failure, so the leak is only reachable by an in-process
341
+ // embedder — which is exactly who this module is for.
342
+ let logger: Logger | null = null;
343
+ if (opts.logDir !== undefined) {
344
+ try {
345
+ logger = (opts.attachLogger ?? defaultAttachLogger)(server.server, opts.logDir);
346
+ } catch (err) {
347
+ // Mirror `close()`'s order and its independent guards, and rethrow the
348
+ // ORIGINAL failure — a secondary close error must not mask the EACCES
349
+ // that actually explains what went wrong.
350
+ try {
351
+ await server.close();
352
+ } catch {
353
+ /* already down, or a socket refused to die; fall through */
354
+ }
355
+ // A forced close destroys sockets, not the async handler promises
356
+ // `http.createServer` discarded. Close resolver admission only after
357
+ // the server has finished its graceful window, then drain any lazy load
358
+ // that was already inside the controller before removing its temp files.
359
+ acceptingHttpModelLoads = false;
360
+ await Promise.allSettled(activeHttpModelLoads);
361
+ try {
362
+ await pagedConfigOverrides.cleanup();
363
+ } catch {
364
+ /* the startup sweep of the next host will reclaim it */
365
+ }
366
+ throw err;
367
+ }
368
+ }
369
+
370
+ const address = server.server.address();
371
+ const boundPort = address !== null && typeof address === 'object' ? address.port : requestedPort;
372
+
373
+ const byName = new Map(models.map((m) => [m.name, m]));
374
+
375
+ // `closeStarted` is an admission latch, checked and flipped synchronously:
376
+ // once close() returns its promise no later out-of-band load can join this
377
+ // set. The operations themselves retain the normal coordinator/controller
378
+ // ordering; shutdown only observes their promises and never takes a lock
379
+ // they need, so queued swaps can drain without deadlocking against close.
380
+ let closeStarted = false;
381
+ const activeHostLoads = new Set<Promise<void>>();
382
+ let closePromise: Promise<void> | null = null;
383
+
384
+ return {
385
+ url: hostUrl(host, boundPort),
386
+ port: boundPort,
387
+ host,
388
+ modelsDir,
389
+ models,
390
+ boundModel: boundEntry.name,
391
+ logDir: logger?.logDir ?? null,
392
+ server,
393
+ health: () => server.health(),
394
+
395
+ loadModel(name: string): Promise<void> {
396
+ if (closeStarted) return Promise.reject(new InferenceHostClosedError());
397
+ if (!byName.has(name)) {
398
+ return Promise.reject(
399
+ new ModelNotFoundError(
400
+ name,
401
+ modelsDir,
402
+ models.map((m) => m.name),
403
+ ),
404
+ );
405
+ }
406
+ // Same nesting as `runGuardedModelLoad`: the drain suspension must be
407
+ // OUTSIDE the writer lock so the armed `clearCache()` timer cannot fire
408
+ // while we are parked waiting for the lock. The HTTP path takes the
409
+ // locks in this same order, so the two can never deadlock against each
410
+ // other on the controller's internal serialization.
411
+ const operation = server.withSuspendedDrains(async () => {
412
+ await server.modelWork.withModelLoad(() => controller.resolveModel(name), name);
413
+ });
414
+ activeHostLoads.add(operation);
415
+ // Use a two-arm `then`, rather than an ignored `finally()` promise:
416
+ // cleanup must not manufacture an unhandled rejection when the caller
417
+ // legitimately observes a failed load through `operation`.
418
+ void operation.then(
419
+ () => activeHostLoads.delete(operation),
420
+ () => activeHostLoads.delete(operation),
421
+ );
422
+ return operation;
423
+ },
424
+
425
+ close(closeOpts?: CloseOptions): Promise<void> {
426
+ if (closePromise !== null) return closePromise;
427
+
428
+ // Flip the latch and snapshot in the same synchronous turn. Every call
429
+ // in the snapshot was fully admitted before shutdown; no later call can
430
+ // race into the set after this point.
431
+ closeStarted = true;
432
+ const admittedHostLoads = [...activeHostLoads];
433
+
434
+ closePromise = (async (): Promise<void> => {
435
+ // Every step is independently guarded: a server that fails to close
436
+ // must still leave the log streams ended and the temp root
437
+ // reclaimed. The temp root is the only one of the three that leaks
438
+ // OUTSIDE this process.
439
+ try {
440
+ await server.close(closeOpts);
441
+ } catch {
442
+ /* already down, or a socket refused to die; fall through */
443
+ }
444
+ // A forced server close destroys request sockets but does not await
445
+ // the async handler promises. Stop any handler that has not reached
446
+ // the resolver from starting a late load, then snapshot resolver calls
447
+ // already inside the controller. Direct calls were closed and
448
+ // snapshotted synchronously above; both groups can now drain without
449
+ // shutdown holding a coordinator/controller lock they need.
450
+ acceptingHttpModelLoads = false;
451
+ const admittedHttpModelLoads = [...activeHttpModelLoads];
452
+ // `allSettled` preserves cleanup when either kind of loader rejects;
453
+ // the original operation still carries that error to its caller.
454
+ await Promise.allSettled([...admittedHostLoads, ...admittedHttpModelLoads]);
455
+ // Only once nothing is still serving. The logger writes a request's
456
+ // record from that request's `finish` handler, so ending the streams
457
+ // first both drops the record and — with no `error` listener — makes
458
+ // the write fatal. `attachLogger` guards the second half; this
459
+ // ordering is what preserves the first.
460
+ if (logger !== null) {
461
+ try {
462
+ await logger.close();
463
+ } catch {
464
+ /* logging is best effort; never block shutdown */
465
+ }
466
+ }
467
+ try {
468
+ await pagedConfigOverrides.cleanup();
469
+ } catch {
470
+ /* the startup sweep of the next host will reclaim it */
471
+ }
472
+ })();
473
+ return closePromise;
474
+ },
475
+ };
476
+ }
477
+
478
+ export { discoverModels, type DiscoveredModel } from './discover.js';
479
+ export {
480
+ applyEnginePolicy,
481
+ engineEnvFor,
482
+ ENGINE_POLICY_ENV_VARS,
483
+ LAUNCHER_ENGINE_POLICY,
484
+ type EnginePolicy,
485
+ } from './env-policy.js';
486
+ export { attachLogger, resolveLogDir, type Logger } from './logger.js';
487
+ export { bracketHost, hostUrl, isLoopbackBindHost, pickFreePort } from './net.js';
488
+ export { resolveMlxNodeHome, resolveModelsDir } from './paths.js';
489
+ export { makeSwapController, type SwapController } from './swap.js';
490
+ export {
491
+ hostTempDirPrefix,
492
+ isProcessAlive,
493
+ sweepOrphanHostTempRoots,
494
+ HOST_TEMP_DIR_STEM,
495
+ type SweepOrphanTempRootsOptions,
496
+ } from './temp-root.js';