@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,69 @@
1
+ /**
2
+ * Engine tuning that is LAUNCHER POLICY, not an engine default.
3
+ *
4
+ * The native engine reads these knobs from the process environment through a
5
+ * `OnceLock`: the FIRST read latches the value for the life of the process, so
6
+ * every variable here must be in place before any model is loaded and must
7
+ * never be mutated afterwards. That one-shot latch is why the policy is a
8
+ * plain data object rather than something a caller can toggle at runtime.
9
+ *
10
+ * Two application shapes, deliberately different:
11
+ *
12
+ * - In-process host (`mlx serve`, `mlx launch claude`): call
13
+ * {@link applyEnginePolicy} to write `process.env` BEFORE the first load.
14
+ * A value the user already set in their shell always wins — this is a
15
+ * default, not an override.
16
+ * - Out-of-process host (Electron `utilityProcess.fork`): pass
17
+ * {@link engineEnvFor} into `fork({ env })` and never touch
18
+ * `process.env` in the child. The returned map is unconditional: it
19
+ * describes the child's starting environment, and the parent is expected
20
+ * to spread the user's own env under it if it wants shell values to win.
21
+ *
22
+ * Keeping the two apart matters. `MLX_PAGED_PREFILL_CHUNK_SIZE` reads as
23
+ * `0` ("no chunking") in Rust when unset; 2048 is the value
24
+ * `mlx launch claude` has historically applied to bound the cold-prefill
25
+ * memory peak, and baking it into the engine would silently change every
26
+ * other embedder's behaviour.
27
+ */
28
+ /** Env var names this module owns. Exported so tests can assert the exact set. */
29
+ export const ENGINE_POLICY_ENV_VARS = ['MLX_PAGED_PREFILL_CHUNK_SIZE'];
30
+ /**
31
+ * The policy `mlx launch claude` has applied since the flag existed, and the
32
+ * one `mlx serve` and the desktop sidecar inherit.
33
+ *
34
+ * 2048 matches the mlx-lm / mlx-vlm default and reduces per-chunk overhead
35
+ * versus the older 1024 for long Qwen dense contexts.
36
+ */
37
+ export const LAUNCHER_ENGINE_POLICY = Object.freeze({ pagedPrefillChunkSize: 2048 });
38
+ /**
39
+ * Render a policy as the environment map a forked child should start with.
40
+ *
41
+ * Unconditional by construction: there is no "already set" to respect in a
42
+ * child that does not exist yet.
43
+ */
44
+ export function engineEnvFor(policy) {
45
+ const env = {};
46
+ if (policy.pagedPrefillChunkSize !== undefined) {
47
+ env.MLX_PAGED_PREFILL_CHUNK_SIZE = String(policy.pagedPrefillChunkSize);
48
+ }
49
+ return env;
50
+ }
51
+ /**
52
+ * Apply a policy to an in-process environment as a DEFAULT.
53
+ *
54
+ * Only writes vars that are currently unset — matching the historical
55
+ * `if (process.env.X == null)` guard, so an explicit `X=` (empty string) in
56
+ * the user's shell is treated as "the user has an opinion" and left alone.
57
+ *
58
+ * Returns the names actually written, so a caller can log or assert on them.
59
+ */
60
+ export function applyEnginePolicy(policy, env = process.env) {
61
+ const applied = [];
62
+ for (const [name, value] of Object.entries(engineEnvFor(policy))) {
63
+ if (env[name] == null) {
64
+ env[name] = value;
65
+ applied.push(name);
66
+ }
67
+ }
68
+ return applied;
69
+ }
@@ -0,0 +1,202 @@
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 type { Server } from 'node:http';
41
+ import { type LoadableModel } from '@mlx-node/lm';
42
+ import type { ServerHealth } from '../health.js';
43
+ import { type CloseOptions, type ServerInstance } from '../server.js';
44
+ import { type DiscoveredModel } from './discover.js';
45
+ import { type EnginePolicy } from './env-policy.js';
46
+ import { type Logger } from './logger.js';
47
+ /** Families `mlx launch claude` has historically forced onto the paged path. */
48
+ export declare const DEFAULT_PAGED_MODEL_TYPES: readonly ['qwen3_5', 'qwen3_5_moe'];
49
+ /** Thrown when `modelsDir` holds nothing servable. Carries the dir for the caller's message. */
50
+ export declare class NoModelsDiscoveredError extends Error {
51
+ readonly modelsDir: string;
52
+ constructor(modelsDir: string);
53
+ }
54
+ /**
55
+ * Thrown when a bind reachable from the network was asked for with no shared
56
+ * secret to gate it.
57
+ *
58
+ * There is no safe way to serve that: every route but the `/health` liveness
59
+ * carve-out runs inference, so an unauthenticated LAN-reachable bind hands
60
+ * anyone who can route to this machine the GPU, the RAM, and the list of
61
+ * models on disk. Failing at startup is the only outcome the operator can act
62
+ * on — serving-with-a-warning is a warning nobody reads scrolling past a
63
+ * model load.
64
+ */
65
+ export declare class InsecureBindError extends Error {
66
+ readonly host: string;
67
+ constructor(host: string);
68
+ }
69
+ /** Thrown when a requested model name is not among the discovered ones. */
70
+ export declare class ModelNotFoundError extends Error {
71
+ readonly requested: string;
72
+ readonly modelsDir: string;
73
+ readonly available: string[];
74
+ constructor(requested: string, modelsDir: string, available: string[]);
75
+ }
76
+ /** Thrown when a model load is requested after host shutdown reaches its admission boundary. */
77
+ export declare class InferenceHostClosedError extends Error {
78
+ constructor();
79
+ }
80
+ export interface InferenceHostOptions {
81
+ /**
82
+ * Port to bind. Omitted ⇒ a free port is picked up-front (so the URL is
83
+ * known before the server starts, which `mlx launch claude` needs in order
84
+ * to bake `ANTHROPIC_BASE_URL` into the child's env). `0` binds an
85
+ * ephemeral port and the real one is read back off the socket.
86
+ */
87
+ port?: number;
88
+ /**
89
+ * Host to bind. Default `127.0.0.1`.
90
+ *
91
+ * A non-loopback value (including the wildcards `0.0.0.0` / `::`) requires
92
+ * an auth token — from {@link InferenceHostOptions.authToken} or
93
+ * `MLX_SERVER_AUTH_TOKEN` — or the call throws {@link InsecureBindError}
94
+ * instead of listening.
95
+ */
96
+ host?: string;
97
+ /** Model discovery root. Default: {@link resolveModelsDir}'s resolution order. */
98
+ modelsDir?: string;
99
+ /**
100
+ * Which discovered model is the bound/default one. Precedence, highest
101
+ * first: this option, `ANTHROPIC_MODEL`, `discovered[0]` (alphabetical).
102
+ * A name that matches nothing discovered throws {@link ModelNotFoundError}
103
+ * rather than silently falling back.
104
+ */
105
+ model?: string;
106
+ /** Shared secret for every route except `/health`. See `ServerConfig.authToken`. */
107
+ authToken?: string;
108
+ /** When set, every HTTP turn is captured under this directory. */
109
+ logDir?: string;
110
+ /**
111
+ * Engine env policy to apply to `process.env` BEFORE anything can load a
112
+ * model. Omitted ⇒ nothing is written and the engine's own defaults stand.
113
+ * Out-of-process hosts must pass `engineEnvFor(policy)` to `fork({ env })`
114
+ * instead and leave this unset — the vars latch via `OnceLock` on first
115
+ * read, so mutating them in the child is too late.
116
+ */
117
+ enginePolicy?: EnginePolicy;
118
+ /** Model types forced onto the block-paged KV cache. Default {@link DEFAULT_PAGED_MODEL_TYPES}. */
119
+ pagedModelTypes?: readonly string[];
120
+ /** Path to the SQLite response store. See `ServerConfig.storePath`. */
121
+ storePath?: string;
122
+ /** Disable response persistence entirely. See `ServerConfig.disableStore`. */
123
+ disableStore?: boolean;
124
+ /**
125
+ * Remove temp roots left behind by hosts that were killed without running
126
+ * `close()`. Default `true`; a supervisor that runs several hosts under one
127
+ * pid namespace and wants to control the timing can turn it off and call
128
+ * {@link sweepOrphanHostTempRoots} itself.
129
+ */
130
+ sweepOrphanTempRoots?: boolean;
131
+ /** Test seam: replaces the native model loader. */
132
+ loadModel?: (path: string) => Promise<LoadableModel>;
133
+ /** Test seam: replaces the verbose request logger. */
134
+ attachLogger?: (server: Server, logDir: string) => Logger;
135
+ }
136
+ export interface InferenceHost {
137
+ /** Connectable base URL, e.g. `http://127.0.0.1:51234`. */
138
+ url: string;
139
+ /** The port actually bound (resolved, never `0`). */
140
+ port: number;
141
+ /** The host actually bound — the requested value, not the advertised one. */
142
+ host: string;
143
+ /** The resolved model discovery root. */
144
+ modelsDir: string;
145
+ /** Everything discovered under `modelsDir`, alphabetical. */
146
+ models: DiscoveredModel[];
147
+ /** Name of the default/bound model. Always a member of {@link models}. */
148
+ boundModel: string;
149
+ /** Verbose log directory, or `null` when logging is off. */
150
+ logDir: string | null;
151
+ /** Escape hatch for callers that need the registry, store, or raw `http.Server`. */
152
+ server: ServerInstance;
153
+ /** Current readiness snapshot — the same body `GET /health` returns. */
154
+ health(): ServerHealth;
155
+ /**
156
+ * Make `name` the resident model, out of band (no HTTP request needed).
157
+ *
158
+ * Runs under the SAME brackets `ServerInstance.loadModel` uses — drains
159
+ * suspended OUTSIDE the coordinator's exclusive writer slot — so it cannot
160
+ * race the process-wide Metal allocator against in-flight inference, and
161
+ * `/health` reports `loading` plus a `lastLoad` record labelled with `name`.
162
+ *
163
+ * It deliberately does NOT call `ServerInstance.loadModel` itself. That
164
+ * helper only ever registers, never unregisters, so calling it per swap
165
+ * would accumulate every model the user has ever picked in memory. The
166
+ * single-resident swap controller is the thing that makes a swap a swap;
167
+ * this method just wraps it in the right brackets.
168
+ *
169
+ * Rejects with {@link ModelNotFoundError} for an unknown name — the swap
170
+ * controller would otherwise treat it as an alias for the current resident,
171
+ * which is right for Claude Code's hardcoded `claude-haiku-*` but wrong for
172
+ * a supervisor that asked for a specific model.
173
+ *
174
+ * Calls admitted before {@link close} begins are allowed to finish. Calls
175
+ * made after shutdown begins reject with {@link InferenceHostClosedError}.
176
+ */
177
+ loadModel(name: string): Promise<void>;
178
+ /**
179
+ * Stop accepting new HTTP and out-of-band load work, wait for every
180
+ * out-of-band load already admitted, then dispose the logger and temp root.
181
+ * Idempotent and memoized; disposal steps are individually guarded so one
182
+ * failure cannot strand the remaining steps.
183
+ */
184
+ close(opts?: CloseOptions): Promise<void>;
185
+ }
186
+ /**
187
+ * Start a local inference host.
188
+ *
189
+ * Throws {@link NoModelsDiscoveredError} / {@link ModelNotFoundError} /
190
+ * {@link InsecureBindError} rather than exiting — a library cannot know
191
+ * whether its caller is a CLI, a test, or an Electron main process. Front-ends
192
+ * render those into their own messages.
193
+ */
194
+ export declare function createInferenceHost(opts?: InferenceHostOptions): Promise<InferenceHost>;
195
+ export { discoverModels, type DiscoveredModel } from './discover.js';
196
+ export { applyEnginePolicy, engineEnvFor, ENGINE_POLICY_ENV_VARS, LAUNCHER_ENGINE_POLICY, type EnginePolicy, } from './env-policy.js';
197
+ export { attachLogger, resolveLogDir, type Logger } from './logger.js';
198
+ export { bracketHost, hostUrl, isLoopbackBindHost, pickFreePort } from './net.js';
199
+ export { resolveMlxNodeHome, resolveModelsDir } from './paths.js';
200
+ export { makeSwapController, type SwapController } from './swap.js';
201
+ export { hostTempDirPrefix, isProcessAlive, sweepOrphanHostTempRoots, HOST_TEMP_DIR_STEM, type SweepOrphanTempRootsOptions, } from './temp-root.js';
202
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"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"}
@@ -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"}