@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
package/dist/router.js CHANGED
@@ -1,10 +1,39 @@
1
1
  /** Path-based router for /v1/* endpoints. */
2
+ import { handleCountMessageTokens } from './endpoints/messages-count-tokens.js';
2
3
  import { handleCreateMessage } from './endpoints/messages.js';
3
4
  import { handleListModels } from './endpoints/models.js';
4
5
  import { handleCreateResponse } from './endpoints/responses.js';
5
6
  import { sendAnthropicBadRequest, sendAnthropicMethodNotAllowed, sendBadRequest, sendMethodNotAllowed, sendNotFound, } from './errors.js';
7
+ import { toMinimalHealth } from './health.js';
6
8
  /** Max request body size (10 MB). */
7
9
  const MAX_BODY_BYTES = 10 * 1024 * 1024;
10
+ /**
11
+ * The request's pathname, parsed against a CONSTANT base.
12
+ *
13
+ * Never against `Host`. That header is attacker-controlled text on every
14
+ * request — `Host: [` makes `new URL()` throw `ERR_INVALID_URL`, and a throw
15
+ * from an async request listener is an unhandled rejection, which under Node's
16
+ * default `--unhandled-rejections=throw` takes the whole process down. One
17
+ * malformed byte from any client that can reach the socket was enough to end
18
+ * inference. A pathname does not depend on the authority anyway, so a fixed
19
+ * base is both safer and equivalent: an absolute-form request URI
20
+ * (`GET http://host/v1/models HTTP/1.1`, legal in HTTP/1.1) still wins over
21
+ * the base and yields the same path it always did.
22
+ *
23
+ * `req.url` itself is guarded too, for the same reason rather than a known
24
+ * input: this function's contract is that no request can make it throw.
25
+ */
26
+ export function requestPathname(req) {
27
+ const raw = req.url ?? '/';
28
+ try {
29
+ return new URL(raw, 'http://localhost').pathname;
30
+ }
31
+ catch {
32
+ const cut = raw.search(/[?#]/);
33
+ const path = cut === -1 ? raw : raw.slice(0, cut);
34
+ return path.startsWith('/') ? path : '/';
35
+ }
36
+ }
8
37
  function readBody(req) {
9
38
  return new Promise((resolve, reject) => {
10
39
  const chunks = [];
@@ -22,15 +51,14 @@ function readBody(req) {
22
51
  req.on('error', reject);
23
52
  });
24
53
  }
25
- export async function routeRequest(req, res, registry, store, responseRetentionSec) {
26
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
27
- const path = url.pathname;
54
+ export async function routeRequest(req, res, registry, store, responseRetentionSec, idleSweeper, resolveModel, listModels, modelWorkCoordinator, extras) {
55
+ const path = requestPathname(req);
28
56
  if (path === '/v1/models') {
29
57
  if (req.method !== 'GET') {
30
58
  sendMethodNotAllowed(res, 'GET');
31
59
  return;
32
60
  }
33
- handleListModels(res, registry);
61
+ handleListModels(res, registry, listModels);
34
62
  return;
35
63
  }
36
64
  if (path === '/v1/responses') {
@@ -48,7 +76,25 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
48
76
  sendBadRequest(res, msg);
49
77
  return;
50
78
  }
51
- await handleCreateResponse(res, body, registry, store, req, responseRetentionSec);
79
+ await handleCreateResponse(res, body, registry, store, req, responseRetentionSec, idleSweeper, modelWorkCoordinator, resolveModel);
80
+ return;
81
+ }
82
+ if (path === '/v1/messages/count_tokens') {
83
+ if (req.method !== 'POST') {
84
+ sendAnthropicMethodNotAllowed(res, 'POST');
85
+ return;
86
+ }
87
+ let body;
88
+ try {
89
+ const raw = await readBody(req);
90
+ body = JSON.parse(raw);
91
+ }
92
+ catch (err) {
93
+ const msg = err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
94
+ sendAnthropicBadRequest(res, msg);
95
+ return;
96
+ }
97
+ await handleCountMessageTokens(res, body, registry, idleSweeper, resolveModel, modelWorkCoordinator);
52
98
  return;
53
99
  }
54
100
  if (path === '/v1/messages') {
@@ -66,12 +112,38 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
66
112
  sendAnthropicBadRequest(res, msg);
67
113
  return;
68
114
  }
69
- await handleCreateMessage(res, body, registry, req);
115
+ await handleCreateMessage(res, body, registry, req, idleSweeper, resolveModel, modelWorkCoordinator);
70
116
  return;
71
117
  }
72
118
  if (path === '/health' || path === '/v1/health') {
119
+ // Deliberately NOT bracketed by `idleSweeper.beginRequest/endRequest`
120
+ // and free of any native call: a supervisor polling on an interval must
121
+ // not keep pushing the drain timer out, nor touch the MLX allocator.
122
+ // Every field is read from plain JavaScript state.
123
+ const health = extras?.health?.();
124
+ if (health === undefined) {
125
+ // No reporter wired (a bare `createHandler` mounted by hand): keep the
126
+ // historical constant so existing consumers are unaffected.
127
+ res.writeHead(200, { 'Content-Type': 'application/json' });
128
+ res.end(JSON.stringify({ status: 'ok' }));
129
+ return;
130
+ }
131
+ // Unauthenticated pollers get liveness only. `models.resident` leaks
132
+ // project names and local paths, so it stays behind the token.
133
+ const body = extras?.authenticated === false ? toMinimalHealth(health) : health;
134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify(body));
136
+ return;
137
+ }
138
+ // Liveness probe at `/`. Claude Code issues `HEAD /` before its first
139
+ // request; respond 200 so the probe doesn't leave a 404 in the logs.
140
+ if (path === '/') {
141
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
142
+ sendMethodNotAllowed(res, 'GET, HEAD');
143
+ return;
144
+ }
73
145
  res.writeHead(200, { 'Content-Type': 'application/json' });
74
- res.end(JSON.stringify({ status: 'ok' }));
146
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify({ service: 'mlx-node' }));
75
147
  return;
76
148
  }
77
149
  sendNotFound(res, `No route matches ${req.method} ${path}`);
package/dist/server.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /** Full HTTP server lifecycle: wires up the handler and periodically sweeps expired `ResponseStore` rows and sessions. */
2
2
  import type { Server } from 'node:http';
3
3
  import { ResponseStore } from '@mlx-node/core';
4
+ import type { PublicModelEntry } from './handler.js';
5
+ import { type ServerHealth } from './health.js';
6
+ import { type LoadModelOptions } from './load-model.js';
7
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
4
8
  import { ModelRegistry } from './registry.js';
5
9
  /**
6
10
  * Parse a positive integer seconds value; returns undefined for unset/invalid so caller can apply its own default.
@@ -25,6 +29,33 @@ export declare function parseEnvSeconds(name: string): number | undefined;
25
29
  * Exported for unit tests.
26
30
  */
27
31
  export declare function parseEnvPositiveInt(name: string): number | undefined;
32
+ /**
33
+ * Resolve the effective auth token from an explicit value plus the env
34
+ * fallback.
35
+ *
36
+ * Exported because `createInferenceHost` has to answer "is this server going
37
+ * to be protected?" BEFORE it binds, in order to refuse a non-loopback bind
38
+ * that would serve anonymously. Two independent copies of this rule would
39
+ * drift, and the direction it would drift is a host that refuses to start
40
+ * while `MLX_SERVER_AUTH_TOKEN` is sitting right there in the environment.
41
+ *
42
+ * An empty env var means "not set". An accidental `MLX_SERVER_AUTH_TOKEN=` in
43
+ * a launcher script must not enable auth with an empty secret that every
44
+ * credential-less request would then fail against.
45
+ *
46
+ * An empty EXPLICIT token is a different case and is rejected outright, for the
47
+ * same reason {@link normalizePositiveIntConfig} rejects a bogus explicit knob:
48
+ * somebody asked for a token and supplied nothing, which is
49
+ * `--auth-token "$TOKEN"` with `TOKEN` unset. Returning `''` made the bind
50
+ * guard read "auth is configured" and allow `0.0.0.0`, while the comparator
51
+ * accepted an empty `x-api-key` because both strings were empty — a wildcard
52
+ * bind published under a credential anyone can guess. Quietly downgrading to
53
+ * "no auth" instead would be fail-open in the other direction: on loopback it
54
+ * hands back the unauthenticated, wildcard-CORS server the operator was
55
+ * explicitly trying not to start. Throwing is the only answer that is wrong in
56
+ * neither bind mode, and it happens before anything binds or loads.
57
+ */
58
+ export declare function resolveAuthToken(explicit: string | undefined): string | undefined;
28
59
  export interface ServerConfig {
29
60
  /** Port to listen on (default: 8080). */
30
61
  port?: number;
@@ -34,8 +65,26 @@ export interface ServerConfig {
34
65
  storePath?: string;
35
66
  /** Disable response storage entirely (default: false). */
36
67
  disableStore?: boolean;
37
- /** Enable CORS headers (default: true). */
68
+ /**
69
+ * Enable CORS headers.
70
+ *
71
+ * Default: `true` when no `authToken` is in effect (historical behaviour),
72
+ * `false` once one is. An explicit value always wins. See
73
+ * {@link ServerConfig.authToken}.
74
+ */
38
75
  cors?: boolean;
76
+ /**
77
+ * Shared secret required on every route except `/health` and `/v1/health`.
78
+ *
79
+ * Default: `process.env.MLX_SERVER_AUTH_TOKEN`, or `undefined` (no auth)
80
+ * when that is unset or empty. `undefined` is byte-for-byte identical to
81
+ * the pre-auth behaviour. An explicit `''` is rejected rather than treated as
82
+ * either — see {@link resolveAuthToken}.
83
+ *
84
+ * Accepted as `x-api-key: <token>` or `authorization: Bearer <token>`.
85
+ * Setting it also flips the `cors` default to `false`.
86
+ */
87
+ authToken?: string;
39
88
  /**
40
89
  * Retention for persisted response rows, in seconds. Stamped as `expires_at`
41
90
  * on each committed response; controls how long `previous_response_id`
@@ -56,6 +105,56 @@ export interface ServerConfig {
56
105
  * `MLX_MAX_QUEUE_DEPTH_PER_MODEL` (positive integer).
57
106
  */
58
107
  maxQueueDepthPerModel?: number;
108
+ /**
109
+ * Milliseconds of HTTP inactivity (no request arrivals or completions)
110
+ * before the server issues a single `clearCache()` to drain the MLX
111
+ * Metal allocator's free pool. Replaces the old per-request
112
+ * `ClearCacheOnDrop` guard in the Rust layer, which was unsafe on a
113
+ * multi-model server because the pool is process-wide.
114
+ *
115
+ * Default: 30_000 ms (30 seconds). `0` disables the sweeper. Env
116
+ * override: `MLX_IDLE_CLEAR_CACHE_MS` (non-negative integer; 0
117
+ * disables; unset falls through to default).
118
+ *
119
+ * The decode-loop drain every 256 tokens inside each generative
120
+ * model is untouched and covers in-flight memory churn.
121
+ */
122
+ idleClearCacheMs?: number;
123
+ /**
124
+ * Optional async callback invoked before the endpoint layer looks
125
+ * the model up in the registry. Intended for lazy-load schemes:
126
+ * the callback should register the model into `registry` if it can;
127
+ * on return, the endpoint does `registry.get(body.model)` and 404s
128
+ * if still unresolved. Callback errors bubble up as 500s.
129
+ */
130
+ resolveModel?: (name: string) => Promise<void>;
131
+ /**
132
+ * Optional override for `GET /v1/models` enumeration. When provided,
133
+ * the endpoint returns this list instead of `registry.list()`.
134
+ * Intended for dynamic discovery schemes (e.g. enumerate every model
135
+ * on disk while only the currently-resident one is registered).
136
+ */
137
+ listModels?: () => PublicModelEntry[];
138
+ }
139
+ /** Options for {@link ServerInstance.close}. */
140
+ export interface CloseOptions {
141
+ /**
142
+ * Grace period, in milliseconds, before still-open connections are
143
+ * destroyed. Default: {@link DEFAULT_CLOSE_TIMEOUT_MS} (5000).
144
+ *
145
+ * Only the FIRST `close()` call's value is honoured — later calls receive
146
+ * the memoized promise of the first, so their timeout is ignored.
147
+ */
148
+ timeoutMs?: number;
149
+ }
150
+ /** Outcome of {@link ServerInstance.close}. */
151
+ export interface CloseResult {
152
+ /** `true` when the grace period expired and connections were destroyed. */
153
+ forced: boolean;
154
+ /** SSE streams open at the moment of the forced destroy. `0` when not forced. */
155
+ streamsAborted: number;
156
+ /** Wall-clock duration of the shutdown. */
157
+ durationMs: number;
59
158
  }
60
159
  export interface ServerInstance {
61
160
  server: Server;
@@ -63,8 +162,99 @@ export interface ServerInstance {
63
162
  registry: ModelRegistry;
64
163
  /** Null when disabled. */
65
164
  store: ResponseStore | null;
66
- /** Graceful shutdown. */
67
- close(): Promise<void>;
165
+ /**
166
+ * Coordinates process-wide MLX work: model loads take the exclusive writer
167
+ * slot, inference takes shared reader slots. Exposed so callers can compose
168
+ * their own brackets (or read `writerActive` / `lastLoad` for diagnostics).
169
+ * Prefer {@link loadModel} for the common load case — it also handles the
170
+ * drain suspension, which is easy to get wrong.
171
+ */
172
+ readonly modelWork: ModelWorkCoordinator;
173
+ /**
174
+ * Current readiness snapshot — the same body an authenticated
175
+ * `GET /health` returns. Pure JavaScript state; no native calls.
176
+ */
177
+ health(): ServerHealth;
178
+ /**
179
+ * Bounded, idempotent shutdown.
180
+ *
181
+ * Stops accepting new connections, drops idle (keep-alive) ones
182
+ * immediately, then waits up to `timeoutMs` for the rest to finish. On
183
+ * expiry every remaining connection is destroyed, which fires the same
184
+ * `res.on('close')` path a client disconnect fires — so in-flight SSE
185
+ * generations are cancelled through `@mlx-node/lm` down to the native
186
+ * `ChatStreamHandle`.
187
+ *
188
+ * Idempotent: the promise is memoized, so repeated calls return the same
189
+ * promise and the same result. In particular a second call does NOT
190
+ * reject with `ERR_SERVER_NOT_RUNNING`.
191
+ *
192
+ * RESIDUAL: `ResponseStore` has no `close()` (it is a Rust-side handle),
193
+ * so the SQLite connection is released by process exit, not here. A
194
+ * long-lived process that creates and closes many servers will hold one
195
+ * store handle per server.
196
+ */
197
+ close(opts?: CloseOptions): Promise<CloseResult>;
198
+ /**
199
+ * Load a model out-of-band and register it, with idle drains suspended and
200
+ * inference excluded for the entire operation — including the wait for the
201
+ * coordinator's writer lock.
202
+ *
203
+ * This is the safe way to swap the resident model on a server that is
204
+ * already serving. Hand-rolling the two brackets in the wrong order races
205
+ * the process-wide Metal allocator; see `load-model.ts` for the full
206
+ * rationale.
207
+ *
208
+ * Rejects with the underlying error if `load()` throws; both brackets
209
+ * unwind cleanly, and `health().lastLoad` records the failure under
210
+ * `opts.name`.
211
+ */
212
+ loadModel(opts: LoadModelOptions): Promise<void>;
213
+ /**
214
+ * Run `fn` with the idle-drain timer suspended for the duration of
215
+ * an unbracketed, allocator-heavy operation — most commonly a hot
216
+ * `Model::load()` invoked AFTER the server has already served at
217
+ * least one request. In that scenario the post-request drain timer
218
+ * armed by `endRequest()` (at t+idleClearCacheMs) can otherwise
219
+ * fire MID-LOAD, racing the Metal allocator while weight
220
+ * materialization is still in progress.
221
+ *
222
+ * Handles try/finally bracketing so a thrown load never leaks the
223
+ * internal suspend counter. Accepts both sync and async functions;
224
+ * returns `fn`'s own return value (or resolved promise). When the
225
+ * bracket exits (normal or thrown) AND `inFlight === 0` with no
226
+ * other suspend active, a fresh drain timer is armed.
227
+ *
228
+ * Safe to nest — each call allocates its own token-scoped release
229
+ * so overlapping brackets unwind independently.
230
+ *
231
+ * The common `serve.ts` pattern (load all models BEFORE
232
+ * `createServer()`) does not need this API — there is no armed
233
+ * timer before the first request, so there is no race.
234
+ *
235
+ * Pass-through when the sweeper is disabled (`idleClearCacheMs: 0`
236
+ * or missing `__internal__.clearCache`): `fn` is invoked directly.
237
+ *
238
+ * @example
239
+ * ```ts
240
+ * await instance.withSuspendedDrains(async () => {
241
+ * const model = await Qwen35Model.load(modelPath);
242
+ * instance.registry.register('new-model', model);
243
+ * });
244
+ * ```
245
+ */
246
+ withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
247
+ withSuspendedDrains<T>(fn: () => T): T;
248
+ /**
249
+ * Low-level: suspend drains and return an idempotent, token-scoped
250
+ * disposer. Prefer {@link withSuspendedDrains} unless you need
251
+ * manual control over when the suspend is released. Safe to nest;
252
+ * calling the disposer more than once is a no-op.
253
+ *
254
+ * No-op when the sweeper is disabled (`idleClearCacheMs: 0` or
255
+ * missing `__internal__.clearCache`): returns a no-op disposer.
256
+ */
257
+ suspendDrains(): () => void;
68
258
  }
69
259
  /**
70
260
  * Start an MLX-Node HTTP server exposing `POST /v1/responses`,
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,0HAA0H;AAI1H,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAIxC,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG/C,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAgB9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOhE;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOpE;AAuBD,MAAM,WAAW,YAAY;IAC3B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,QAAQ,EAAE,aAAa,CAAC;IACxB,0BAA0B;IAC1B,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,yBAAyB;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAoEjF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -5,9 +5,23 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { ResponseStore } from '@mlx-node/core';
7
7
  import { createHandler } from './handler.js';
8
+ import { createHealthReporter } from './health.js';
9
+ import { createIdleSweeper, DEFAULT_IDLE_CLEAR_CACHE_MS, parseIdleClearCacheEnv } from './idle-sweeper.js';
10
+ import { runGuardedModelLoad } from './load-model.js';
11
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
8
12
  import { ModelRegistry } from './registry.js';
13
+ import { activeSSEStreamCountForResponses } from './streaming.js';
9
14
  /** Cleanup interval for expired responses (ms). */
10
15
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
16
+ /**
17
+ * Default grace period before {@link ServerInstance.close} destroys
18
+ * still-open connections.
19
+ *
20
+ * 5 s is deliberately a little longer than the ~5 s GPU watchdog window: a
21
+ * decode loop that is about to yield its next token should get the chance to
22
+ * unwind cleanly rather than being cut off a hair early.
23
+ */
24
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
11
25
  /**
12
26
  * Default retention for persisted response rows in SQLite, in seconds.
13
27
  *
@@ -82,6 +96,42 @@ function normalizePositiveIntConfig(value, name) {
82
96
  }
83
97
  return value;
84
98
  }
99
+ /**
100
+ * Resolve the effective auth token from an explicit value plus the env
101
+ * fallback.
102
+ *
103
+ * Exported because `createInferenceHost` has to answer "is this server going
104
+ * to be protected?" BEFORE it binds, in order to refuse a non-loopback bind
105
+ * that would serve anonymously. Two independent copies of this rule would
106
+ * drift, and the direction it would drift is a host that refuses to start
107
+ * while `MLX_SERVER_AUTH_TOKEN` is sitting right there in the environment.
108
+ *
109
+ * An empty env var means "not set". An accidental `MLX_SERVER_AUTH_TOKEN=` in
110
+ * a launcher script must not enable auth with an empty secret that every
111
+ * credential-less request would then fail against.
112
+ *
113
+ * An empty EXPLICIT token is a different case and is rejected outright, for the
114
+ * same reason {@link normalizePositiveIntConfig} rejects a bogus explicit knob:
115
+ * somebody asked for a token and supplied nothing, which is
116
+ * `--auth-token "$TOKEN"` with `TOKEN` unset. Returning `''` made the bind
117
+ * guard read "auth is configured" and allow `0.0.0.0`, while the comparator
118
+ * accepted an empty `x-api-key` because both strings were empty — a wildcard
119
+ * bind published under a credential anyone can guess. Quietly downgrading to
120
+ * "no auth" instead would be fail-open in the other direction: on loopback it
121
+ * hands back the unauthenticated, wildcard-CORS server the operator was
122
+ * explicitly trying not to start. Throwing is the only answer that is wrong in
123
+ * neither bind mode, and it happens before anything binds or loads.
124
+ */
125
+ export function resolveAuthToken(explicit) {
126
+ if (explicit === '') {
127
+ throw new Error('authToken was set to an empty string; pass a real secret or omit it entirely ' +
128
+ '(an unset shell variable in `--auth-token "$VAR"` is the usual cause).');
129
+ }
130
+ if (explicit !== undefined)
131
+ return explicit;
132
+ const fromEnv = process.env.MLX_SERVER_AUTH_TOKEN;
133
+ return fromEnv != null && fromEnv !== '' ? fromEnv : undefined;
134
+ }
85
135
  /**
86
136
  * Start an MLX-Node HTTP server exposing `POST /v1/responses`,
87
137
  * `POST /v1/messages`, and `GET /v1/models`.
@@ -95,7 +145,10 @@ function normalizePositiveIntConfig(value, name) {
95
145
  export async function createServer(config) {
96
146
  const port = config?.port ?? 8080;
97
147
  const host = config?.host ?? '127.0.0.1';
98
- const cors = config?.cors ?? true;
148
+ const authToken = resolveAuthToken(config?.authToken);
149
+ // Forwarded unresolved so `createHandler` applies the auth-aware default
150
+ // (`true` without a token, `false` with one) in exactly one place.
151
+ const cors = config?.cors;
99
152
  const disableStore = config?.disableStore ?? false;
100
153
  // Validate caller-supplied numeric knobs BEFORE consulting env fallbacks
101
154
  // so a bogus explicit value surfaces as a descriptive error instead of
@@ -108,7 +161,18 @@ export async function createServer(config) {
108
161
  // allocated on `register()`) all share a single effective value.
109
162
  const configMaxQueueDepth = normalizePositiveIntConfig(config?.maxQueueDepthPerModel, 'maxQueueDepthPerModel');
110
163
  const maxQueueDepthPerModel = configMaxQueueDepth ?? parseEnvPositiveInt('MLX_MAX_QUEUE_DEPTH_PER_MODEL');
164
+ // Idle sweeper wiring. `0` is a legal explicit "off" value so we
165
+ // cannot reuse `normalizePositiveIntConfig` (which rejects 0).
166
+ // Precedence: explicit config value wins over env wins over default.
167
+ const idleClearCacheMs = resolveIdleClearCacheMs(config?.idleClearCacheMs);
168
+ const idleSweeper = createIdleSweeper(idleClearCacheMs);
169
+ // The sweeper is driven exclusively by `endRequest()` calls in the
170
+ // inference endpoints (`/v1/responses`, `/v1/messages`). There is no
171
+ // cold-start drain: a process that loads models but never receives a
172
+ // request will not fire `clearCache()`. See the `idle-sweeper.ts`
173
+ // module doc (section "Drain is post-request only") for rationale.
111
174
  const registry = new ModelRegistry({ maxQueueDepth: maxQueueDepthPerModel });
175
+ const modelWorkCoordinator = new ModelWorkCoordinator();
112
176
  let store = null;
113
177
  if (!disableStore) {
114
178
  const storePath = config?.storePath ?? join(homedir(), '.mlx-node', 'responses.db');
@@ -126,8 +190,33 @@ export async function createServer(config) {
126
190
  }
127
191
  }, CLEANUP_INTERVAL_MS);
128
192
  cleanupTimer.unref();
129
- const handler = createHandler(registry, { cors, store, responseRetentionSec });
130
- const server = httpCreateServer(handler);
193
+ // One reporter shared by the HTTP endpoint and `ServerInstance.health()`
194
+ // so both report the same uptime origin.
195
+ const health = createHealthReporter({ registry, idleSweeper, modelWorkCoordinator });
196
+ const handler = createHandler(registry, {
197
+ cors,
198
+ store,
199
+ responseRetentionSec,
200
+ idleSweeper,
201
+ resolveModel: config?.resolveModel,
202
+ modelWorkCoordinator,
203
+ listModels: config?.listModels,
204
+ authToken,
205
+ health,
206
+ });
207
+ /**
208
+ * Responses owned by THIS HTTP server. The SSE registry is deliberately
209
+ * process-wide because `beginSSE` is also used by standalone handlers;
210
+ * shutdown accounting intersects it with this weak ownership set so one
211
+ * server cannot claim streams that another server leaves live. A WeakSet
212
+ * needs no second response cleanup lifecycle.
213
+ */
214
+ const ownedResponses = new WeakSet();
215
+ const server = httpCreateServer((req, res) => {
216
+ // Synchronous, before `handler` can reach either endpoint's `beginSSE`.
217
+ ownedResponses.add(res);
218
+ void handler(req, res);
219
+ });
131
220
  await new Promise((resolve, reject) => {
132
221
  const onError = (err) => {
133
222
  server.removeListener('error', onError);
@@ -139,20 +228,94 @@ export async function createServer(config) {
139
228
  resolve();
140
229
  });
141
230
  });
142
- return {
143
- server,
144
- registry,
145
- store,
146
- async close() {
231
+ // Memoized so a second `close()` returns the first call's promise instead
232
+ // of re-entering `server.close()` (which invokes its callback with
233
+ // ERR_SERVER_NOT_RUNNING once the server is already down).
234
+ let closePromise = null;
235
+ const close = (opts) => {
236
+ if (closePromise !== null)
237
+ return closePromise;
238
+ const startedAt = Date.now();
239
+ const requested = opts?.timeoutMs;
240
+ const timeoutMs = typeof requested === 'number' && Number.isFinite(requested) && requested >= 0
241
+ ? requested
242
+ : DEFAULT_CLOSE_TIMEOUT_MS;
243
+ closePromise = (async () => {
147
244
  clearInterval(cleanupTimer);
148
- await new Promise((resolve, reject) => {
245
+ idleSweeper.close();
246
+ let forced = false;
247
+ let streamsAborted = 0;
248
+ // Arm the wait BEFORE dropping idle sockets so nothing can complete in
249
+ // the gap and settle `server.close()` before we are listening.
250
+ const serverClosed = new Promise((resolve, reject) => {
149
251
  server.close((err) => {
150
- if (err)
252
+ // Tolerated defensively: memoization should make this unreachable,
253
+ // but a caller who also closed `instance.server` directly would
254
+ // otherwise turn a successful shutdown into a rejection.
255
+ if (err && err.code !== 'ERR_SERVER_NOT_RUNNING')
151
256
  reject(err);
152
257
  else
153
258
  resolve();
154
259
  });
155
260
  });
261
+ // Keep-alive sockets parked in a client pool hold no request; without
262
+ // this the shutdown would sit on them until the client's own timeout.
263
+ server.closeIdleConnections();
264
+ const forceTimer = setTimeout(() => {
265
+ forced = true;
266
+ // Snapshot BEFORE destroying: `closeAllConnections()` fires each
267
+ // response's `'close'` event, which unregisters it from the global SSE
268
+ // registry used by the intersection.
269
+ streamsAborted = activeSSEStreamCountForResponses(ownedResponses);
270
+ // Destroying the socket fires exactly the `res.on('close')` path a
271
+ // client disconnect fires, so the endpoint's AbortController cancels
272
+ // the native stream handle rather than leaking a running decode.
273
+ server.closeAllConnections();
274
+ }, timeoutMs);
275
+ try {
276
+ await serverClosed;
277
+ }
278
+ finally {
279
+ clearTimeout(forceTimer);
280
+ }
281
+ return { forced, streamsAborted, durationMs: Date.now() - startedAt };
282
+ })();
283
+ return closePromise;
284
+ };
285
+ return {
286
+ server,
287
+ registry,
288
+ store,
289
+ modelWork: modelWorkCoordinator,
290
+ health,
291
+ close,
292
+ loadModel(opts) {
293
+ return runGuardedModelLoad({ idleSweeper, modelWorkCoordinator, registry }, opts);
294
+ },
295
+ withSuspendedDrains(fn) {
296
+ return idleSweeper.withSuspendedDrains(fn);
297
+ },
298
+ suspendDrains() {
299
+ return idleSweeper.suspendDrains();
156
300
  },
157
301
  };
158
302
  }
303
+ /**
304
+ * Resolve the effective idle-drain delay from (constructor value, env,
305
+ * default). Unlike the other knobs, `0` is a legal explicit opt-out —
306
+ * we must NOT fall through to env/default when the caller explicitly
307
+ * passes `0`. Returns a non-negative integer milliseconds value.
308
+ */
309
+ function resolveIdleClearCacheMs(configValue) {
310
+ if (configValue !== undefined) {
311
+ if (typeof configValue !== 'number' ||
312
+ !Number.isFinite(configValue) ||
313
+ !Number.isInteger(configValue) ||
314
+ configValue < 0) {
315
+ throw new Error(`idleClearCacheMs must be a non-negative integer; received ${String(configValue)}`);
316
+ }
317
+ return configValue;
318
+ }
319
+ const envValue = parseIdleClearCacheEnv();
320
+ return envValue ?? DEFAULT_IDLE_CLEAR_CACHE_MS;
321
+ }