@mlx-node/server 0.0.12 → 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.
- package/dist/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/** Full HTTP server lifecycle: wires up the handler and periodically sweeps expired `ResponseStore` rows and sessions. */
|
|
2
|
+
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { createServer as httpCreateServer } from 'node:http';
|
|
5
|
+
import type { Server, ServerResponse } from 'node:http';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { ResponseStore } from '@mlx-node/core';
|
|
10
|
+
|
|
11
|
+
import type { PublicModelEntry } from './handler.js';
|
|
12
|
+
import { createHandler } from './handler.js';
|
|
13
|
+
import { createHealthReporter, type ServerHealth } from './health.js';
|
|
14
|
+
import { createIdleSweeper, DEFAULT_IDLE_CLEAR_CACHE_MS, parseIdleClearCacheEnv } from './idle-sweeper.js';
|
|
15
|
+
import { runGuardedModelLoad, type LoadModelOptions } from './load-model.js';
|
|
16
|
+
import { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
17
|
+
import { ModelRegistry } from './registry.js';
|
|
18
|
+
import { activeSSEStreamCountForResponses } from './streaming.js';
|
|
19
|
+
|
|
20
|
+
/** Cleanup interval for expired responses (ms). */
|
|
21
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default grace period before {@link ServerInstance.close} destroys
|
|
25
|
+
* still-open connections.
|
|
26
|
+
*
|
|
27
|
+
* 5 s is deliberately a little longer than the ~5 s GPU watchdog window: a
|
|
28
|
+
* decode loop that is about to yield its next token should get the chance to
|
|
29
|
+
* unwind cleanly rather than being cut off a hair early.
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default retention for persisted response rows in SQLite, in seconds.
|
|
35
|
+
*
|
|
36
|
+
* Decoupled from the in-memory `SessionRegistry` TTL (30 min) so a client
|
|
37
|
+
* sending `previous_response_id` after the warm KV cache has been evicted
|
|
38
|
+
* can still cold-replay from disk via `reconstructMessagesFromChain` +
|
|
39
|
+
* `ChatSession.startFromHistory`. 7 days trades disk for continuity at the
|
|
40
|
+
* cost of a one-time prefill on recovery.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_RESPONSE_RETENTION_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default per-model queue-depth cap (waiters-only; the actively running
|
|
46
|
+
* dispatch does not count). Applied when neither
|
|
47
|
+
* {@link ServerConfig.maxQueueDepthPerModel} nor the
|
|
48
|
+
* `MLX_MAX_QUEUE_DEPTH_PER_MODEL` env var is set; the config sentinel
|
|
49
|
+
* `'unbounded'` opts out entirely.
|
|
50
|
+
*
|
|
51
|
+
* 16, not 8: an agent fan-out (e.g. Claude Code bursting parallel tool
|
|
52
|
+
* calls) can put well over 8 concurrent requests behind one model from a
|
|
53
|
+
* SINGLE client, so 8 risked 429s on a lone user. Over-cap requests get
|
|
54
|
+
* HTTP 429 with `Retry-After: 1`, which the OpenAI/Anthropic SDK retry
|
|
55
|
+
* loops honor — the cap sheds pile-up instead of failing well-behaved
|
|
56
|
+
* clients.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL = 16;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse a positive integer seconds value; returns undefined for unset/invalid so caller can apply its own default.
|
|
62
|
+
*
|
|
63
|
+
* Non-integer positive values (e.g. `"1.5"`) are rejected rather than
|
|
64
|
+
* silently truncated — a typo like `"1.5"` meant as `"15"` would otherwise
|
|
65
|
+
* be accepted as 1 second, expiring persisted response rows almost
|
|
66
|
+
* immediately and breaking `previous_response_id` continuity. We prefer
|
|
67
|
+
* falling through to the caller's default over crashing on startup so a
|
|
68
|
+
* config-template typo in a Dockerfile / CI manifest does not take the
|
|
69
|
+
* service down.
|
|
70
|
+
*
|
|
71
|
+
* Exported for unit tests.
|
|
72
|
+
*/
|
|
73
|
+
export function parseEnvSeconds(name: string): number | undefined {
|
|
74
|
+
const raw = process.env[name];
|
|
75
|
+
if (raw == null || raw === '') return undefined;
|
|
76
|
+
const parsed = Number(raw);
|
|
77
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
78
|
+
if (!Number.isInteger(parsed)) return undefined;
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a positive integer count from env; shares the reject-unset-or-invalid
|
|
84
|
+
* semantics used by {@link parseEnvSeconds} (including the non-integer
|
|
85
|
+
* reject) so callers can fall back to their own default when the var is
|
|
86
|
+
* missing or malformed.
|
|
87
|
+
*
|
|
88
|
+
* Exported for unit tests.
|
|
89
|
+
*/
|
|
90
|
+
export function parseEnvPositiveInt(name: string): number | undefined {
|
|
91
|
+
const raw = process.env[name];
|
|
92
|
+
if (raw == null || raw === '') return undefined;
|
|
93
|
+
const parsed = Number(raw);
|
|
94
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
95
|
+
if (!Number.isInteger(parsed)) return undefined;
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a caller-supplied positive-integer config knob.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors the reject-invalid semantics of {@link parseEnvPositiveInt} /
|
|
103
|
+
* {@link parseEnvSeconds} but fails fast with a descriptive error when
|
|
104
|
+
* the caller explicitly passes a bogus value. Silent coercion would hide
|
|
105
|
+
* a config bug that can take the model offline (e.g. a
|
|
106
|
+
* `maxQueueDepthPerModel: 0` makes `queuedCount >= limit` true for every
|
|
107
|
+
* request, immediately returning HTTP 429; a `responseRetentionSec: 0`
|
|
108
|
+
* stamps `expires_at = now` on every row and the next cleanup sweep
|
|
109
|
+
* deletes it). `undefined` falls through so the env/default path still
|
|
110
|
+
* applies.
|
|
111
|
+
*/
|
|
112
|
+
function normalizePositiveIntConfig(value: number | undefined, name: string): number | undefined {
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
115
|
+
throw new Error(`${name} must be a positive integer; received ${String(value)}`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the effective auth token from an explicit value plus the env
|
|
122
|
+
* fallback.
|
|
123
|
+
*
|
|
124
|
+
* Exported because `createInferenceHost` has to answer "is this server going
|
|
125
|
+
* to be protected?" BEFORE it binds, in order to refuse a non-loopback bind
|
|
126
|
+
* that would serve anonymously. Two independent copies of this rule would
|
|
127
|
+
* drift, and the direction it would drift is a host that refuses to start
|
|
128
|
+
* while `MLX_SERVER_AUTH_TOKEN` is sitting right there in the environment.
|
|
129
|
+
*
|
|
130
|
+
* An empty env var means "not set". An accidental `MLX_SERVER_AUTH_TOKEN=` in
|
|
131
|
+
* a launcher script must not enable auth with an empty secret that every
|
|
132
|
+
* credential-less request would then fail against.
|
|
133
|
+
*
|
|
134
|
+
* An empty EXPLICIT token is a different case and is rejected outright, for the
|
|
135
|
+
* same reason {@link normalizePositiveIntConfig} rejects a bogus explicit knob:
|
|
136
|
+
* somebody asked for a token and supplied nothing, which is
|
|
137
|
+
* `--auth-token "$TOKEN"` with `TOKEN` unset. Returning `''` made the bind
|
|
138
|
+
* guard read "auth is configured" and allow `0.0.0.0`, while the comparator
|
|
139
|
+
* accepted an empty `x-api-key` because both strings were empty — a wildcard
|
|
140
|
+
* bind published under a credential anyone can guess. Quietly downgrading to
|
|
141
|
+
* "no auth" instead would be fail-open in the other direction: on loopback it
|
|
142
|
+
* hands back the unauthenticated, wildcard-CORS server the operator was
|
|
143
|
+
* explicitly trying not to start. Throwing is the only answer that is wrong in
|
|
144
|
+
* neither bind mode, and it happens before anything binds or loads.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveAuthToken(explicit: string | undefined): string | undefined {
|
|
147
|
+
if (explicit === '') {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'authToken was set to an empty string; pass a real secret or omit it entirely ' +
|
|
150
|
+
'(an unset shell variable in `--auth-token "$VAR"` is the usual cause).',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (explicit !== undefined) return explicit;
|
|
154
|
+
const fromEnv = process.env.MLX_SERVER_AUTH_TOKEN;
|
|
155
|
+
return fromEnv != null && fromEnv !== '' ? fromEnv : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ServerConfig {
|
|
159
|
+
/** Port to listen on (default: 8080). */
|
|
160
|
+
port?: number;
|
|
161
|
+
/** Hostname to bind to (default: '127.0.0.1'). */
|
|
162
|
+
host?: string;
|
|
163
|
+
/** Path to the SQLite response store (default: ~/.mlx-node/responses.db). */
|
|
164
|
+
storePath?: string;
|
|
165
|
+
/** Disable response storage entirely (default: false). */
|
|
166
|
+
disableStore?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Enable CORS headers.
|
|
169
|
+
*
|
|
170
|
+
* Default: `true` when no `authToken` is in effect (historical behaviour),
|
|
171
|
+
* `false` once one is. An explicit value always wins. See
|
|
172
|
+
* {@link ServerConfig.authToken}.
|
|
173
|
+
*/
|
|
174
|
+
cors?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Shared secret required on every route except `/health` and `/v1/health`.
|
|
177
|
+
*
|
|
178
|
+
* Default: `process.env.MLX_SERVER_AUTH_TOKEN`, or `undefined` (no auth)
|
|
179
|
+
* when that is unset or empty. `undefined` is byte-for-byte identical to
|
|
180
|
+
* the pre-auth behaviour. An explicit `''` is rejected rather than treated as
|
|
181
|
+
* either — see {@link resolveAuthToken}.
|
|
182
|
+
*
|
|
183
|
+
* Accepted as `x-api-key: <token>` or `authorization: Bearer <token>`.
|
|
184
|
+
* Setting it also flips the `cors` default to `false`.
|
|
185
|
+
*/
|
|
186
|
+
authToken?: string;
|
|
187
|
+
/**
|
|
188
|
+
* Retention for persisted response rows, in seconds. Stamped as `expires_at`
|
|
189
|
+
* on each committed response; controls how long `previous_response_id`
|
|
190
|
+
* cold-replay from SQLite remains possible after the warm session is evicted.
|
|
191
|
+
*
|
|
192
|
+
* Default: 7 days. Env override: `MLX_RESPONSE_RETENTION_SECONDS`. Ignored
|
|
193
|
+
* when `disableStore` is true.
|
|
194
|
+
*/
|
|
195
|
+
responseRetentionSec?: number;
|
|
196
|
+
/**
|
|
197
|
+
* Maximum number of concurrent requests that may be WAITING for the
|
|
198
|
+
* per-model execution mutex (the one actively running does not count).
|
|
199
|
+
* When the cap is reached, further requests return HTTP 429 with a
|
|
200
|
+
* `Retry-After: 1` header so clients can back off instead of piling
|
|
201
|
+
* into an unbounded queue.
|
|
202
|
+
*
|
|
203
|
+
* Default: {@link DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL} (16). Env
|
|
204
|
+
* override: `MLX_MAX_QUEUE_DEPTH_PER_MODEL` (positive integer). Pass
|
|
205
|
+
* `'unbounded'` to opt out of the cap entirely (the pre-cap
|
|
206
|
+
* behaviour); the sentinel also bypasses the env override, because an
|
|
207
|
+
* explicit config value always wins over env.
|
|
208
|
+
*/
|
|
209
|
+
maxQueueDepthPerModel?: number | 'unbounded';
|
|
210
|
+
/**
|
|
211
|
+
* Milliseconds of HTTP inactivity (no request arrivals or completions)
|
|
212
|
+
* before the server issues a single `clearCache()` to drain the MLX
|
|
213
|
+
* Metal allocator's free pool. Replaces the old per-request
|
|
214
|
+
* `ClearCacheOnDrop` guard in the Rust layer, which was unsafe on a
|
|
215
|
+
* multi-model server because the pool is process-wide.
|
|
216
|
+
*
|
|
217
|
+
* Default: 30_000 ms (30 seconds). `0` disables the sweeper. Env
|
|
218
|
+
* override: `MLX_IDLE_CLEAR_CACHE_MS` (non-negative integer; 0
|
|
219
|
+
* disables; unset falls through to default).
|
|
220
|
+
*
|
|
221
|
+
* The decode-loop drain every 256 tokens inside each generative
|
|
222
|
+
* model is untouched and covers in-flight memory churn.
|
|
223
|
+
*/
|
|
224
|
+
idleClearCacheMs?: number;
|
|
225
|
+
/**
|
|
226
|
+
* Optional async callback invoked before the endpoint layer looks
|
|
227
|
+
* the model up in the registry. Intended for lazy-load schemes:
|
|
228
|
+
* the callback should register the model into `registry` if it can;
|
|
229
|
+
* on return, the endpoint does `registry.get(body.model)` and 404s
|
|
230
|
+
* if still unresolved. Callback errors bubble up as 500s.
|
|
231
|
+
*/
|
|
232
|
+
resolveModel?: (name: string) => Promise<void>;
|
|
233
|
+
/**
|
|
234
|
+
* Optional override for `GET /v1/models` enumeration. When provided,
|
|
235
|
+
* the endpoint returns this list instead of `registry.list()`.
|
|
236
|
+
* Intended for dynamic discovery schemes (e.g. enumerate every model
|
|
237
|
+
* on disk while only the currently-resident one is registered).
|
|
238
|
+
*/
|
|
239
|
+
listModels?: () => PublicModelEntry[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Options for {@link ServerInstance.close}. */
|
|
243
|
+
export interface CloseOptions {
|
|
244
|
+
/**
|
|
245
|
+
* Grace period, in milliseconds, before still-open connections are
|
|
246
|
+
* destroyed. Default: {@link DEFAULT_CLOSE_TIMEOUT_MS} (5000).
|
|
247
|
+
*
|
|
248
|
+
* Only the FIRST `close()` call's value is honoured — later calls receive
|
|
249
|
+
* the memoized promise of the first, so their timeout is ignored.
|
|
250
|
+
*/
|
|
251
|
+
timeoutMs?: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Outcome of {@link ServerInstance.close}. */
|
|
255
|
+
export interface CloseResult {
|
|
256
|
+
/** `true` when the grace period expired and connections were destroyed. */
|
|
257
|
+
forced: boolean;
|
|
258
|
+
/** SSE streams open at the moment of the forced destroy. `0` when not forced. */
|
|
259
|
+
streamsAborted: number;
|
|
260
|
+
/** Wall-clock duration of the shutdown. */
|
|
261
|
+
durationMs: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ServerInstance {
|
|
265
|
+
server: Server;
|
|
266
|
+
/** Register models before or after starting. */
|
|
267
|
+
registry: ModelRegistry;
|
|
268
|
+
/** Null when disabled. */
|
|
269
|
+
store: ResponseStore | null;
|
|
270
|
+
/**
|
|
271
|
+
* Coordinates process-wide MLX work: model loads take the exclusive writer
|
|
272
|
+
* slot, inference takes shared reader slots. Exposed so callers can compose
|
|
273
|
+
* their own brackets (or read `writerActive` / `lastLoad` for diagnostics).
|
|
274
|
+
* Prefer {@link loadModel} for the common load case — it also handles the
|
|
275
|
+
* drain suspension, which is easy to get wrong.
|
|
276
|
+
*/
|
|
277
|
+
readonly modelWork: ModelWorkCoordinator;
|
|
278
|
+
/**
|
|
279
|
+
* Current readiness snapshot — the same body an authenticated
|
|
280
|
+
* `GET /health` returns. Pure JavaScript state; no native calls.
|
|
281
|
+
*/
|
|
282
|
+
health(): ServerHealth;
|
|
283
|
+
/**
|
|
284
|
+
* Bounded, idempotent shutdown.
|
|
285
|
+
*
|
|
286
|
+
* Stops accepting new connections, drops idle (keep-alive) ones
|
|
287
|
+
* immediately, then waits up to `timeoutMs` for the rest to finish. On
|
|
288
|
+
* expiry every remaining connection is destroyed, which fires the same
|
|
289
|
+
* `res.on('close')` path a client disconnect fires — so in-flight SSE
|
|
290
|
+
* generations are cancelled through `@mlx-node/lm` down to the native
|
|
291
|
+
* `ChatStreamHandle`.
|
|
292
|
+
*
|
|
293
|
+
* Idempotent: the promise is memoized, so repeated calls return the same
|
|
294
|
+
* promise and the same result. In particular a second call does NOT
|
|
295
|
+
* reject with `ERR_SERVER_NOT_RUNNING`.
|
|
296
|
+
*
|
|
297
|
+
* RESIDUAL: `ResponseStore` has no `close()` (it is a Rust-side handle),
|
|
298
|
+
* so the SQLite connection is released by process exit, not here. A
|
|
299
|
+
* long-lived process that creates and closes many servers will hold one
|
|
300
|
+
* store handle per server.
|
|
301
|
+
*/
|
|
302
|
+
close(opts?: CloseOptions): Promise<CloseResult>;
|
|
303
|
+
/**
|
|
304
|
+
* Load a model out-of-band and register it, with idle drains suspended and
|
|
305
|
+
* inference excluded for the entire operation — including the wait for the
|
|
306
|
+
* coordinator's writer lock.
|
|
307
|
+
*
|
|
308
|
+
* This is the safe way to swap the resident model on a server that is
|
|
309
|
+
* already serving. Hand-rolling the two brackets in the wrong order races
|
|
310
|
+
* the process-wide Metal allocator; see `load-model.ts` for the full
|
|
311
|
+
* rationale.
|
|
312
|
+
*
|
|
313
|
+
* Rejects with the underlying error if `load()` throws; both brackets
|
|
314
|
+
* unwind cleanly, and `health().lastLoad` records the failure under
|
|
315
|
+
* `opts.name`.
|
|
316
|
+
*/
|
|
317
|
+
loadModel(opts: LoadModelOptions): Promise<void>;
|
|
318
|
+
/**
|
|
319
|
+
* Run `fn` with the idle-drain timer suspended for the duration of
|
|
320
|
+
* an unbracketed, allocator-heavy operation — most commonly a hot
|
|
321
|
+
* `Model::load()` invoked AFTER the server has already served at
|
|
322
|
+
* least one request. In that scenario the post-request drain timer
|
|
323
|
+
* armed by `endRequest()` (at t+idleClearCacheMs) can otherwise
|
|
324
|
+
* fire MID-LOAD, racing the Metal allocator while weight
|
|
325
|
+
* materialization is still in progress.
|
|
326
|
+
*
|
|
327
|
+
* Handles try/finally bracketing so a thrown load never leaks the
|
|
328
|
+
* internal suspend counter. Accepts both sync and async functions;
|
|
329
|
+
* returns `fn`'s own return value (or resolved promise). When the
|
|
330
|
+
* bracket exits (normal or thrown) AND `inFlight === 0` with no
|
|
331
|
+
* other suspend active, a fresh drain timer is armed.
|
|
332
|
+
*
|
|
333
|
+
* Safe to nest — each call allocates its own token-scoped release
|
|
334
|
+
* so overlapping brackets unwind independently.
|
|
335
|
+
*
|
|
336
|
+
* The common `serve.ts` pattern (load all models BEFORE
|
|
337
|
+
* `createServer()`) does not need this API — there is no armed
|
|
338
|
+
* timer before the first request, so there is no race.
|
|
339
|
+
*
|
|
340
|
+
* Pass-through when the sweeper is disabled (`idleClearCacheMs: 0`
|
|
341
|
+
* or missing `__internal__.clearCache`): `fn` is invoked directly.
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* ```ts
|
|
345
|
+
* await instance.withSuspendedDrains(async () => {
|
|
346
|
+
* const model = await Qwen35Model.load(modelPath);
|
|
347
|
+
* instance.registry.register('new-model', model);
|
|
348
|
+
* });
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
|
|
352
|
+
withSuspendedDrains<T>(fn: () => T): T;
|
|
353
|
+
/**
|
|
354
|
+
* Low-level: suspend drains and return an idempotent, token-scoped
|
|
355
|
+
* disposer. Prefer {@link withSuspendedDrains} unless you need
|
|
356
|
+
* manual control over when the suspend is released. Safe to nest;
|
|
357
|
+
* calling the disposer more than once is a no-op.
|
|
358
|
+
*
|
|
359
|
+
* No-op when the sweeper is disabled (`idleClearCacheMs: 0` or
|
|
360
|
+
* missing `__internal__.clearCache`): returns a no-op disposer.
|
|
361
|
+
*/
|
|
362
|
+
suspendDrains(): () => void;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Start an MLX-Node HTTP server exposing `POST /v1/responses`,
|
|
367
|
+
* `POST /v1/messages`, and `GET /v1/models`.
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* ```typescript
|
|
371
|
+
* const { registry, close } = await createServer({ port: 8080 });
|
|
372
|
+
* registry.register('qwen3.5-3b', await Qwen35Model.load('./models/qwen3.5-3b'));
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
export async function createServer(config?: ServerConfig): Promise<ServerInstance> {
|
|
376
|
+
const port = config?.port ?? 8080;
|
|
377
|
+
const host = config?.host ?? '127.0.0.1';
|
|
378
|
+
const authToken = resolveAuthToken(config?.authToken);
|
|
379
|
+
// Forwarded unresolved so `createHandler` applies the auth-aware default
|
|
380
|
+
// (`true` without a token, `false` with one) in exactly one place.
|
|
381
|
+
const cors = config?.cors;
|
|
382
|
+
const disableStore = config?.disableStore ?? false;
|
|
383
|
+
// Validate caller-supplied numeric knobs BEFORE consulting env fallbacks
|
|
384
|
+
// so a bogus explicit value surfaces as a descriptive error instead of
|
|
385
|
+
// silently falling through to env / default. See
|
|
386
|
+
// `normalizePositiveIntConfig` for the failure modes we're guarding.
|
|
387
|
+
const configRetentionSec = normalizePositiveIntConfig(config?.responseRetentionSec, 'responseRetentionSec');
|
|
388
|
+
const responseRetentionSec =
|
|
389
|
+
configRetentionSec ?? parseEnvSeconds('MLX_RESPONSE_RETENTION_SECONDS') ?? DEFAULT_RESPONSE_RETENTION_SECONDS;
|
|
390
|
+
// Queue-depth cap; resolved exactly once at server construction so the
|
|
391
|
+
// registry (and its per-model `SessionRegistry` instances allocated on
|
|
392
|
+
// `register()`) all share a single effective value. Precedence:
|
|
393
|
+
// explicit config (the `'unbounded'` sentinel disables the cap
|
|
394
|
+
// outright, short-circuiting env) wins over env wins over the default
|
|
395
|
+
// of {@link DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL}. Bogus numeric config
|
|
396
|
+
// still fails fast in `normalizePositiveIntConfig` before any env /
|
|
397
|
+
// default fallback applies.
|
|
398
|
+
const maxQueueDepthConfig = config?.maxQueueDepthPerModel;
|
|
399
|
+
const maxQueueDepthPerModel =
|
|
400
|
+
maxQueueDepthConfig === 'unbounded'
|
|
401
|
+
? undefined
|
|
402
|
+
: (normalizePositiveIntConfig(maxQueueDepthConfig, 'maxQueueDepthPerModel') ??
|
|
403
|
+
parseEnvPositiveInt('MLX_MAX_QUEUE_DEPTH_PER_MODEL') ??
|
|
404
|
+
DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL);
|
|
405
|
+
|
|
406
|
+
// Idle sweeper wiring. `0` is a legal explicit "off" value so we
|
|
407
|
+
// cannot reuse `normalizePositiveIntConfig` (which rejects 0).
|
|
408
|
+
// Precedence: explicit config value wins over env wins over default.
|
|
409
|
+
const idleClearCacheMs = resolveIdleClearCacheMs(config?.idleClearCacheMs);
|
|
410
|
+
const idleSweeper = createIdleSweeper(idleClearCacheMs);
|
|
411
|
+
// The sweeper is driven exclusively by `endRequest()` calls in the
|
|
412
|
+
// inference endpoints (`/v1/responses`, `/v1/messages`). There is no
|
|
413
|
+
// cold-start drain: a process that loads models but never receives a
|
|
414
|
+
// request will not fire `clearCache()`. See the `idle-sweeper.ts`
|
|
415
|
+
// module doc (section "Drain is post-request only") for rationale.
|
|
416
|
+
const registry = new ModelRegistry({ maxQueueDepth: maxQueueDepthPerModel });
|
|
417
|
+
const modelWorkCoordinator = new ModelWorkCoordinator(maxQueueDepthPerModel);
|
|
418
|
+
|
|
419
|
+
let store: ResponseStore | null = null;
|
|
420
|
+
if (!disableStore) {
|
|
421
|
+
const storePath = config?.storePath ?? join(homedir(), '.mlx-node', 'responses.db');
|
|
422
|
+
const storeDir = join(storePath, '..');
|
|
423
|
+
await mkdir(storeDir, { recursive: true });
|
|
424
|
+
store = await ResponseStore.open(storePath);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Always schedule the sweep — sessions need TTL sweeps even without a store.
|
|
428
|
+
const cleanupTimer: ReturnType<typeof setInterval> = setInterval(() => {
|
|
429
|
+
if (store) {
|
|
430
|
+
store.cleanupExpired().catch(() => {});
|
|
431
|
+
}
|
|
432
|
+
for (const sessReg of registry.listSessionRegistries()) {
|
|
433
|
+
sessReg.sweep();
|
|
434
|
+
}
|
|
435
|
+
}, CLEANUP_INTERVAL_MS);
|
|
436
|
+
cleanupTimer.unref();
|
|
437
|
+
|
|
438
|
+
// One reporter shared by the HTTP endpoint and `ServerInstance.health()`
|
|
439
|
+
// so both report the same uptime origin.
|
|
440
|
+
const health = createHealthReporter({ registry, idleSweeper, modelWorkCoordinator });
|
|
441
|
+
|
|
442
|
+
const handler = createHandler(registry, {
|
|
443
|
+
cors,
|
|
444
|
+
store,
|
|
445
|
+
responseRetentionSec,
|
|
446
|
+
idleSweeper,
|
|
447
|
+
resolveModel: config?.resolveModel,
|
|
448
|
+
modelWorkCoordinator,
|
|
449
|
+
listModels: config?.listModels,
|
|
450
|
+
authToken,
|
|
451
|
+
health,
|
|
452
|
+
});
|
|
453
|
+
/**
|
|
454
|
+
* Responses owned by THIS HTTP server. The SSE registry is deliberately
|
|
455
|
+
* process-wide because `beginSSE` is also used by standalone handlers;
|
|
456
|
+
* shutdown accounting intersects it with this weak ownership set so one
|
|
457
|
+
* server cannot claim streams that another server leaves live. A WeakSet
|
|
458
|
+
* needs no second response cleanup lifecycle.
|
|
459
|
+
*/
|
|
460
|
+
const ownedResponses = new WeakSet<ServerResponse>();
|
|
461
|
+
const server = httpCreateServer((req, res) => {
|
|
462
|
+
// Synchronous, before `handler` can reach either endpoint's `beginSSE`.
|
|
463
|
+
ownedResponses.add(res);
|
|
464
|
+
void handler(req, res);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await new Promise<void>((resolve, reject) => {
|
|
468
|
+
const onError = (err: Error) => {
|
|
469
|
+
server.removeListener('error', onError);
|
|
470
|
+
reject(err);
|
|
471
|
+
};
|
|
472
|
+
server.on('error', onError);
|
|
473
|
+
server.listen(port, host, () => {
|
|
474
|
+
server.removeListener('error', onError);
|
|
475
|
+
resolve();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Memoized so a second `close()` returns the first call's promise instead
|
|
480
|
+
// of re-entering `server.close()` (which invokes its callback with
|
|
481
|
+
// ERR_SERVER_NOT_RUNNING once the server is already down).
|
|
482
|
+
let closePromise: Promise<CloseResult> | null = null;
|
|
483
|
+
|
|
484
|
+
const close = (opts?: CloseOptions): Promise<CloseResult> => {
|
|
485
|
+
if (closePromise !== null) return closePromise;
|
|
486
|
+
const startedAt = Date.now();
|
|
487
|
+
const requested = opts?.timeoutMs;
|
|
488
|
+
const timeoutMs =
|
|
489
|
+
typeof requested === 'number' && Number.isFinite(requested) && requested >= 0
|
|
490
|
+
? requested
|
|
491
|
+
: DEFAULT_CLOSE_TIMEOUT_MS;
|
|
492
|
+
|
|
493
|
+
closePromise = (async (): Promise<CloseResult> => {
|
|
494
|
+
clearInterval(cleanupTimer);
|
|
495
|
+
idleSweeper.close();
|
|
496
|
+
|
|
497
|
+
let forced = false;
|
|
498
|
+
let streamsAborted = 0;
|
|
499
|
+
|
|
500
|
+
// Arm the wait BEFORE dropping idle sockets so nothing can complete in
|
|
501
|
+
// the gap and settle `server.close()` before we are listening.
|
|
502
|
+
const serverClosed = new Promise<void>((resolve, reject) => {
|
|
503
|
+
server.close((err) => {
|
|
504
|
+
// Tolerated defensively: memoization should make this unreachable,
|
|
505
|
+
// but a caller who also closed `instance.server` directly would
|
|
506
|
+
// otherwise turn a successful shutdown into a rejection.
|
|
507
|
+
if (err && (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') reject(err);
|
|
508
|
+
else resolve();
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Keep-alive sockets parked in a client pool hold no request; without
|
|
513
|
+
// this the shutdown would sit on them until the client's own timeout.
|
|
514
|
+
server.closeIdleConnections();
|
|
515
|
+
|
|
516
|
+
const forceTimer = setTimeout(() => {
|
|
517
|
+
forced = true;
|
|
518
|
+
// Snapshot BEFORE destroying: `closeAllConnections()` fires each
|
|
519
|
+
// response's `'close'` event, which unregisters it from the global SSE
|
|
520
|
+
// registry used by the intersection.
|
|
521
|
+
streamsAborted = activeSSEStreamCountForResponses(ownedResponses);
|
|
522
|
+
// Destroying the socket fires exactly the `res.on('close')` path a
|
|
523
|
+
// client disconnect fires, so the endpoint's AbortController cancels
|
|
524
|
+
// the native stream handle rather than leaking a running decode.
|
|
525
|
+
server.closeAllConnections();
|
|
526
|
+
}, timeoutMs);
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await serverClosed;
|
|
530
|
+
} finally {
|
|
531
|
+
clearTimeout(forceTimer);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { forced, streamsAborted, durationMs: Date.now() - startedAt };
|
|
535
|
+
})();
|
|
536
|
+
|
|
537
|
+
return closePromise;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
server,
|
|
542
|
+
registry,
|
|
543
|
+
store,
|
|
544
|
+
modelWork: modelWorkCoordinator,
|
|
545
|
+
health,
|
|
546
|
+
close,
|
|
547
|
+
loadModel(opts: LoadModelOptions): Promise<void> {
|
|
548
|
+
return runGuardedModelLoad({ idleSweeper, modelWorkCoordinator, registry }, opts);
|
|
549
|
+
},
|
|
550
|
+
withSuspendedDrains<T>(fn: () => T | Promise<T>): T | Promise<T> {
|
|
551
|
+
return idleSweeper.withSuspendedDrains(fn as () => Promise<T>);
|
|
552
|
+
},
|
|
553
|
+
suspendDrains(): () => void {
|
|
554
|
+
return idleSweeper.suspendDrains();
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Resolve the effective idle-drain delay from (constructor value, env,
|
|
561
|
+
* default). Unlike the other knobs, `0` is a legal explicit opt-out —
|
|
562
|
+
* we must NOT fall through to env/default when the caller explicitly
|
|
563
|
+
* passes `0`. Returns a non-negative integer milliseconds value.
|
|
564
|
+
*/
|
|
565
|
+
function resolveIdleClearCacheMs(configValue: number | undefined): number {
|
|
566
|
+
if (configValue !== undefined) {
|
|
567
|
+
if (
|
|
568
|
+
typeof configValue !== 'number' ||
|
|
569
|
+
!Number.isFinite(configValue) ||
|
|
570
|
+
!Number.isInteger(configValue) ||
|
|
571
|
+
configValue < 0
|
|
572
|
+
) {
|
|
573
|
+
throw new Error(`idleClearCacheMs must be a non-negative integer; received ${String(configValue)}`);
|
|
574
|
+
}
|
|
575
|
+
return configValue;
|
|
576
|
+
}
|
|
577
|
+
const envValue = parseIdleClearCacheEnv();
|
|
578
|
+
return envValue ?? DEFAULT_IDLE_CLEAR_CACHE_MS;
|
|
579
|
+
}
|