@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.
- package/dist/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +72 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +22 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +608 -85
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +69 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +70 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +95 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +201 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +37 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +79 -7
- package/dist/server.d.ts +193 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +173 -10
- package/dist/session-registry.d.ts +279 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +518 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- 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"}
|