@mlx-node/server 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. package/src/types.ts +470 -0
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Idle cache-pool sweeper.
3
+ *
4
+ * The MLX Metal allocator holds a process-wide free pool. Without an
5
+ * explicit drain the pool can sit near the wired ceiling (tens of GB on
6
+ * an M3 Max) for the entire server uptime. `@mlx-node/core`'s decode
7
+ * loop already calls `mlx_clear_cache()` every 256 steps, but that
8
+ * cadence only fires while a generation is in progress — idle periods
9
+ * between HTTP requests never get a drain.
10
+ *
11
+ * An earlier iteration put a `ClearCacheOnDrop` RAII guard around every
12
+ * session command so the pool was flushed after every turn. That is
13
+ * wrong on a multi-model server: the free pool is shared across model
14
+ * instances, so flushing after a request on model A discards blocks
15
+ * that model B's next turn is about to reuse.
16
+ *
17
+ * The round-1 replacement was a debounced `touch()`-based sweeper:
18
+ * every request start / end reset a 30s timer that fired `clearCache()`
19
+ * when the timer expired. That design had a correctness bug flagged in
20
+ * round-2 review: a long-running request (streaming decode of 60+
21
+ * seconds) had its 30s timer armed at ARRIVAL, and the timer fired
22
+ * mid-request at t=30s — exactly when the generation thread still
23
+ * needed those blocks. Worse, `clearCache()` routes through
24
+ * `mlx_synchronize()` WITHOUT a stream argument, which only drains the
25
+ * default stream (see `crates/mlx-sys/mlx/mlx/scheduler.cpp`), but
26
+ * decode runs on custom streams — so the drain could race with live
27
+ * command buffers, risking buffer use-after-free.
28
+ *
29
+ * This version fixes both by tracking `inFlight` explicitly. The timer
30
+ * is ONLY armed when the counter returns to zero, and is cancelled the
31
+ * instant any new request arrives — mid-request drains are impossible
32
+ * by construction.
33
+ *
34
+ * # Scope: inference endpoints only
35
+ *
36
+ * The counter is bumped by `/v1/responses` and `/v1/messages` ONLY.
37
+ * Non-inference traffic — `/v1/models`, `/v1/health`, CORS `OPTIONS`
38
+ * preflights, 404s for unknown routes — deliberately does NOT touch
39
+ * the sweeper. Counting those would let a `GET /v1/models` on every
40
+ * client startup, a CORS preflight from a browser, or a cron health
41
+ * probe keep the allocator pinned forever.
42
+ *
43
+ * # Drain is post-request only
44
+ *
45
+ * The only drain path is post-request: `endRequest()` decrements the
46
+ * in-flight counter and, on the 1 -> 0 transition, arms the timer. A
47
+ * cold-idle server that loaded models but never served a request
48
+ * never drains. That is deliberate:
49
+ *
50
+ * - Earlier iterations armed a cold-start drain from either
51
+ * `createServer()` (regressed on slow model loads > `idleClearCacheMs`,
52
+ * firing mid-load) or from `ModelRegistry.register()` (round-7 review
53
+ * surfaced that it still raced sequential multi-model loads — the
54
+ * timer armed by `register(A)` could fire while `await load(B)` was
55
+ * still resolving). Loader-bracketing would require API changes on
56
+ * the user-facing load path, so we remove the cold-start path
57
+ * entirely instead.
58
+ * - Load-time allocator growth is bounded (a few GB of scratch), not
59
+ * the tens-of-GB problem the post-request drain targets. macOS
60
+ * handles truly-idle memory pressure via compression / swap.
61
+ * - Python `mlx-lm` has no cold-start drain either, so this matches
62
+ * that baseline for servers that never receive a request.
63
+ *
64
+ * # Hot-load bracketing: `withSuspendedDrains`
65
+ *
66
+ * Hot-load flows — a `Model::load()` invoked AFTER the server has
67
+ * already served at least one request — race the post-request drain
68
+ * timer: the t+delayMs timer armed by the previous `endRequest()` can
69
+ * fire MID-LOAD while weight materialization is still allocating
70
+ * through the Metal free pool. The canonical fix is to bracket the
71
+ * load with `withSuspendedDrains(fn)`:
72
+ *
73
+ * ```ts
74
+ * await server.withSuspendedDrains(async () => {
75
+ * const model = await Qwen35MoeModel.load(modelPath);
76
+ * server.registry.register('qwen', model);
77
+ * });
78
+ * ```
79
+ *
80
+ * `withSuspendedDrains` handles try/finally bracketing itself, so a
81
+ * thrown load never leaks the internal suspend counter — a footgun the
82
+ * earlier raw `suspendDrains()` / `resumeDrains()` pair exposed
83
+ * (round-9 MEDIUM). The low-level `suspendDrains()` entry point
84
+ * remains available for callers that genuinely need manual control;
85
+ * its returned disposer is token-scoped and idempotent.
86
+ *
87
+ * `withSuspendedDrains` is also thenable-safe: non-Promise thenables
88
+ * (plain objects or even callables that expose a `.then` method) are
89
+ * normalized via `Promise.resolve()` so callers always get a real
90
+ * Promise<T> back and the suspend-release fires on settle. A throwing
91
+ * `.then` getter is treated as non-thenable (the getter access is
92
+ * guarded); the value is returned directly and the suspend releases
93
+ * synchronously — we deliberately do NOT propagate a getter throw, on
94
+ * the theory that leaking the suspend is a far worse failure mode than
95
+ * losing a pathological value (round-10 MEDIUM).
96
+ *
97
+ * # Tuning
98
+ *
99
+ * - The default (30_000 ms) balances "give models a chance to reuse
100
+ * hot blocks across back-to-back requests" against "don't hold
101
+ * tens of GB hostage while the process is truly idle".
102
+ * - `idleClearCacheMs: 0` disables the sweeper entirely — useful for
103
+ * benchmarks or single-model workloads where the only memory
104
+ * pressure is the decode-loop cadence already in place.
105
+ * `withSuspendedDrains(fn)` on the disabled sweeper is a pass-through
106
+ * so call sites can unconditionally bracket.
107
+ * - `MLX_IDLE_CLEAR_CACHE_MS` env var overrides the server's
108
+ * constructor value; explicit constructor value wins over env.
109
+ *
110
+ * Drain cost is a single `mlx_synchronize` + `mlx_clear_cache` —
111
+ * constant time relative to generation length.
112
+ */
113
+
114
+ // `clearCache` lives under the `__internal__` NAPI namespace — it is
115
+ // deliberately NOT re-exported on the root `@mlx-node/core` object to
116
+ // keep the process-wide, custom-stream drain out of the public
117
+ // surface. User code that deep-imports from `@mlx-node/core` must
118
+ // acknowledge the namespace (`core.__internal__.clearCache`) and
119
+ // read the `@internal` caveat there. See
120
+ // `crates/mlx-core/src/cache_limit.rs`.
121
+ //
122
+ // We import the module namespace (NOT a destructured `__internal__`)
123
+ // so that a stale `.node` binary missing the `__internal__` namespace
124
+ // does NOT hard-fail the import of `@mlx-node/server` itself. The
125
+ // `clearCache` symbol is resolved LAZILY inside `createIdleSweeper`
126
+ // (see round-5 Finding A): if the namespace / function is absent on
127
+ // the loaded binding we warn once and fall back to a no-op drain,
128
+ // keeping the server runnable on partial upgrades / downgrades. The
129
+ // expected resolution path is `core.__internal__.clearCache` — the
130
+ // sweeper never touches the root namespace so there is no ambiguity
131
+ // with the deliberately-omitted root-level `clearCache` export.
132
+ import * as core from '@mlx-node/core';
133
+
134
+ /** Default idle window before draining the allocator's free pool (ms). */
135
+ export const DEFAULT_IDLE_CLEAR_CACHE_MS = 30_000;
136
+
137
+ /**
138
+ * Promise/A+ thenable probe that is robust against:
139
+ *
140
+ * - Function-typed thenables (awkward but legal — a callable that
141
+ * also exposes a `.then` method). The earlier probe only checked
142
+ * `typeof === 'object'`, so those values fell through to the
143
+ * synchronous branch and released the suspend before the async
144
+ * work had even started (round-10 MEDIUM #2).
145
+ * - A throwing `.then` getter. Accessing the property is guarded by
146
+ * try/catch so a pathological value can't escape before the
147
+ * caller's `release()` runs. We deliberately prefer "return false
148
+ * on getter throw" over "propagate" here: the whole point of this
149
+ * helper is to avoid leaking the suspend counter, and the caller
150
+ * treats a non-thenable result as immediately-released.
151
+ */
152
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
153
+ if (value == null) return false;
154
+ const kind = typeof value;
155
+ if (kind !== 'object' && kind !== 'function') return false;
156
+ try {
157
+ return typeof (value as { then?: unknown }).then === 'function';
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Parse `MLX_IDLE_CLEAR_CACHE_MS`. Same semantics as the other env
165
+ * knobs in `server.ts`: finite non-negative integer or fall through
166
+ * to the caller's default. A value of `0` explicitly disables the
167
+ * sweeper; negative / non-integer / unparseable values are ignored.
168
+ */
169
+ export function parseIdleClearCacheEnv(): number | undefined {
170
+ const raw = process.env.MLX_IDLE_CLEAR_CACHE_MS;
171
+ if (raw == null || raw === '') return undefined;
172
+ const parsed = Number(raw);
173
+ if (!Number.isFinite(parsed) || parsed < 0) return undefined;
174
+ if (!Number.isInteger(parsed)) return undefined;
175
+ return parsed;
176
+ }
177
+
178
+ /**
179
+ * In-flight counter-based idle-drain scheduler.
180
+ *
181
+ * - `beginRequest()` is called ONCE per inference request BEFORE the
182
+ * native model is dispatched. It increments the in-flight counter
183
+ * and cancels any pending drain timer — eliminating the mid-request
184
+ * drain race the debounced-`touch()` design had. Only the inference
185
+ * endpoints (`/v1/responses` + `/v1/messages`) should call this;
186
+ * `/v1/models`, `/v1/health`, and CORS preflights MUST NOT, or they
187
+ * would keep the allocator pinned forever on purely observational
188
+ * traffic.
189
+ * - `endRequest()` is called ONCE per request in a `finally` block
190
+ * after the model stream has fully ended (covering success, error,
191
+ * and client-abort paths). It decrements the counter; when the
192
+ * counter reaches zero it arms a `delayMs` timer that calls
193
+ * `onDrain()` on expiry. This is the ONLY path that ever schedules
194
+ * a drain — see the module-level "Drain is post-request only"
195
+ * note for why cold-start arming was removed.
196
+ * - `close()` cancels any pending drain — used during graceful
197
+ * shutdown so the timer does not keep Node alive after
198
+ * `server.close()`. It intentionally does NOT reset the counter:
199
+ * draining partway through in-flight requests is the exact failure
200
+ * mode we're avoiding.
201
+ *
202
+ * Thread-safety is provided by Node's single-threaded event loop —
203
+ * `beginRequest` / `endRequest` are only ever invoked from the HTTP
204
+ * handler (or tests). Every `beginRequest()` MUST be paired with
205
+ * exactly one `endRequest()` on every exit path — a missed
206
+ * `endRequest()` would leave the counter pinned above zero and the
207
+ * drain would never fire.
208
+ */
209
+ export interface IdleSweeper {
210
+ /** Mark a request as arrived. Cancels any pending drain. */
211
+ beginRequest(): void;
212
+ /**
213
+ * Mark a request as completed. When the in-flight counter reaches
214
+ * zero, arms a drain timer for `delayMs`.
215
+ */
216
+ endRequest(): void;
217
+ /** Cancel the pending drain. Idempotent. Does NOT reset the counter. */
218
+ close(): void;
219
+ /**
220
+ * Run `fn` with drains suspended. Handles try/finally bracketing so
221
+ * a thrown load never leaks the internal suspend counter. This is
222
+ * the canonical entry point for hot-load flows — a `Model::load()`
223
+ * invoked AFTER the server has already served at least one request.
224
+ * In that scenario the post-request drain timer armed by
225
+ * `endRequest()` (t+delayMs) can otherwise fire MID-LOAD while
226
+ * weight materialization is still allocating through the Metal free
227
+ * pool, racing the allocator state.
228
+ *
229
+ * Accepts both sync and async functions; returns `fn`'s own return
230
+ * value (or resolved promise). On exit (normal or thrown), the
231
+ * suspend token is disposed exactly once — the drain timer is
232
+ * re-armed if `inFlight === 0` and no other suspend is active.
233
+ *
234
+ * Thenable-safe: non-Promise thenables (including function-typed
235
+ * values that expose a `.then` method) are normalized via
236
+ * `Promise.resolve()` so callers always receive a real `Promise<T>`
237
+ * on the async branch; a throwing `.then` getter is guarded and the
238
+ * value is treated as non-thenable rather than leaking the suspend.
239
+ *
240
+ * The common `serve.ts` pattern — load all models before
241
+ * `createServer(...)`, i.e. before any request is served — has no
242
+ * armed timer in the first place and therefore does NOT need to
243
+ * bracket.
244
+ *
245
+ * Pass-through on the disabled sweeper (`delayMs <= 0`): `fn` is
246
+ * invoked directly and its return value is returned unchanged, so
247
+ * call sites can unconditionally bracket without branching.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * await server.withSuspendedDrains(async () => {
252
+ * const model = await Qwen35MoeModel.load(modelPath);
253
+ * server.registry.register('qwen', model);
254
+ * });
255
+ * ```
256
+ */
257
+ withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
258
+ withSuspendedDrains<T>(fn: () => T): T;
259
+ /**
260
+ * Low-level: suspend drains and return an idempotent disposer.
261
+ * Prefer {@link withSuspendedDrains} unless you need manual
262
+ * control over when the suspend is released (for example across
263
+ * async boundaries the caller wants to manage explicitly).
264
+ *
265
+ * Semantics: cancels any pending drain and increments an internal
266
+ * "load" counter. Returns a disposer — calling the disposer
267
+ * decrements the counter. When the counter reaches zero AND
268
+ * `inFlight === 0`, a fresh drain timer is armed so a subsequent
269
+ * idle window still drains.
270
+ *
271
+ * Safe to nest: N suspends require N dispose calls. The returned
272
+ * disposer is token-scoped and idempotent — calling it more than
273
+ * once is a no-op, so it cannot over-decrement and mysteriously
274
+ * shift the idle window.
275
+ *
276
+ * No-op on the disabled sweeper (`delayMs <= 0`): returns a no-op
277
+ * disposer so call sites can unconditionally bracket without
278
+ * branching on whether the sweeper is enabled.
279
+ */
280
+ suspendDrains(): () => void;
281
+ /** Observability hook — `true` while a drain is scheduled. */
282
+ readonly isPending: boolean;
283
+ /** Observability hook — current in-flight request count. */
284
+ readonly inFlight: number;
285
+ }
286
+
287
+ /** Set to `true` after the first missing-binding warn so we don't spam stderr. */
288
+ let __warnedMissingClearCache = false;
289
+
290
+ /**
291
+ * Resolve `__internal__.clearCache` from the loaded `@mlx-node/core`
292
+ * binding, guarded against stale / partial / downgraded `.node` files.
293
+ *
294
+ * Round-5 Finding A: the previous design dereferenced
295
+ * `__internal__.clearCache` at MODULE scope, so the import of
296
+ * `@mlx-node/server` itself threw `TypeError: Cannot read properties
297
+ * of undefined (reading 'clearCache')` if the loaded native binding
298
+ * lacked the namespace. That was unrecoverable from user code —
299
+ * there was no escape hatch, not even `idleClearCacheMs: 0`, because
300
+ * the throw fired before `createServer()` was reached.
301
+ *
302
+ * This resolver probes the namespace defensively at sweeper-creation
303
+ * time (and ONLY when the sweeper is actually enabled, so the
304
+ * `delayMs <= 0` opt-out short-circuits before we even look). Missing
305
+ * namespace / missing function / wrong-type function all route to a
306
+ * one-time `console.warn` + no-op fallback so the server stays up.
307
+ */
308
+ function resolveClearCache(): () => void {
309
+ // Read through the module namespace — `core.__internal__` returns
310
+ // `undefined` when the loaded binding predates the namespace, WITHOUT
311
+ // throwing. Using an optional chain on the function keeps the probe
312
+ // safe even if `__internal__` exists but is some exotic shape.
313
+ const fn: unknown = core.__internal__?.clearCache;
314
+ if (typeof fn === 'function') {
315
+ return fn as () => void;
316
+ }
317
+ if (!__warnedMissingClearCache) {
318
+ __warnedMissingClearCache = true;
319
+ // Intentionally a `console.warn`, NOT a throw — the server must
320
+ // stay up on a stale native binding; the user's error surface is
321
+ // stderr, not an uncaught exception on a module-import side-effect.
322
+ console.warn(
323
+ '[@mlx-node/server] __internal__.clearCache not found on @mlx-node/core; idle cache drains disabled. Rebuild @mlx-node/core.',
324
+ );
325
+ }
326
+ return (): void => {};
327
+ }
328
+
329
+ /**
330
+ * Create an idle sweeper. Pass `0` or a non-positive value to opt out —
331
+ * the returned object becomes a no-op that still satisfies the
332
+ * interface. Callers can therefore unconditionally wire
333
+ * `beginRequest()` / `endRequest()` without branching on whether the
334
+ * sweeper is enabled.
335
+ *
336
+ * When `onDrain` is omitted, the sweeper resolves
337
+ * `__internal__.clearCache` on `@mlx-node/core` at creation time and
338
+ * caches the result in the returned closure. A missing namespace /
339
+ * function triggers a one-time `console.warn` and a no-op fallback —
340
+ * see `resolveClearCache()`. The `delayMs <= 0` path skips the
341
+ * resolution entirely so the opt-out remains purely passive.
342
+ */
343
+ export function createIdleSweeper(delayMs: number, onDrain?: () => void): IdleSweeper {
344
+ if (!Number.isFinite(delayMs) || delayMs <= 0) {
345
+ // No-op sweeper: no timer to cancel, no counter that could latch.
346
+ // `withSuspendedDrains` becomes a pass-through so call sites can
347
+ // unconditionally bracket without branching on whether the
348
+ // sweeper is enabled. `suspendDrains` returns a no-op disposer
349
+ // for the same reason.
350
+ function passthrough<T>(fn: () => Promise<T>): Promise<T>;
351
+ function passthrough<T>(fn: () => T): T;
352
+ function passthrough<T>(fn: () => T | Promise<T>): T | Promise<T> {
353
+ return fn();
354
+ }
355
+ return {
356
+ beginRequest(): void {},
357
+ endRequest(): void {},
358
+ close(): void {},
359
+ withSuspendedDrains: passthrough,
360
+ suspendDrains(): () => void {
361
+ return (): void => {};
362
+ },
363
+ get isPending(): boolean {
364
+ return false;
365
+ },
366
+ get inFlight(): number {
367
+ return 0;
368
+ },
369
+ };
370
+ }
371
+
372
+ // Resolve the drain callback exactly once per sweeper — either the
373
+ // caller-supplied `onDrain` (used by tests for observability) or the
374
+ // guarded `__internal__.clearCache` lookup. Caching in the closure
375
+ // means the namespace probe runs ONCE per sweeper, not once per
376
+ // timer firing.
377
+ const drainFn: () => void = onDrain ?? resolveClearCache();
378
+
379
+ let timer: ReturnType<typeof setTimeout> | null = null;
380
+ let inFlight = 0;
381
+ // `loadCounter` tracks active `suspendDrains()` brackets. Any value
382
+ // > 0 means some caller has declared "do not fire a drain right now
383
+ // — a long-running, unbracketed allocator-heavy operation is in
384
+ // progress". `scheduleDrain()` bails when it's positive, and the
385
+ // drain-fire callback re-checks it defensively before calling
386
+ // `drainFn()` in case a suspend arrived between arming and firing
387
+ // on the same event-loop tick.
388
+ let loadCounter = 0;
389
+
390
+ const cancelTimer = (): void => {
391
+ if (timer !== null) {
392
+ clearTimeout(timer);
393
+ timer = null;
394
+ }
395
+ };
396
+
397
+ const onTimerFire = (): void => {
398
+ timer = null;
399
+ // Double-check: if a request arrived between the timer being
400
+ // armed and the callback firing, `inFlight` is non-zero and
401
+ // `beginRequest()` should already have cancelled us. This guard
402
+ // is belt-and-suspenders — shouldn't trip in practice but
403
+ // protects against a timer racing a synchronous
404
+ // `beginRequest()` in adversarial tests.
405
+ if (inFlight !== 0) return;
406
+ // Equivalent defensive re-check for the suspend path: a
407
+ // `suspendDrains()` that landed between arming and firing
408
+ // (possible if the arming `setTimeout` callback is queued
409
+ // alongside a synchronous suspend in the same tick) should
410
+ // reschedule rather than drain mid-load. The microtask ordering
411
+ // of `setTimeout` + `clearTimeout` makes this nearly impossible
412
+ // in practice on Node's single-threaded loop — but the check is
413
+ // cheap and future-proofs against timer-pool refactors.
414
+ if (loadCounter > 0) {
415
+ // Re-arm for another window. We intentionally do NOT call
416
+ // `drainFn()` here. If the suspend clears before the next
417
+ // `delayMs` elapses, the disposer returned by `suspendDrains()`
418
+ // will cancel this timer and start fresh anyway.
419
+ timer = setTimeout(onTimerFire, delayMs);
420
+ timer.unref();
421
+ return;
422
+ }
423
+ try {
424
+ drainFn();
425
+ } catch {
426
+ // Swallow — drain is best-effort and must not crash the
427
+ // server loop. The underlying FFI call can't currently fail
428
+ // but we defend against future refactors growing fallibility.
429
+ }
430
+ };
431
+
432
+ const scheduleDrain = (): void => {
433
+ // Caller must already have confirmed `inFlight === 0`. We
434
+ // defensively null-out the existing timer first so overlapping
435
+ // begin→end→begin→end patterns can't leak a timer.
436
+ cancelTimer();
437
+ // Skip arming while a suspend bracket is active — the matching
438
+ // disposer that takes the counter back to zero will arm a fresh
439
+ // timer on the transition (provided `inFlight === 0`).
440
+ if (loadCounter > 0) return;
441
+ timer = setTimeout(onTimerFire, delayMs);
442
+ // Do not keep the event loop alive on this timer alone. If the
443
+ // server would otherwise exit (e.g. the HTTP listener closed),
444
+ // we do not want to force a last-ditch drain.
445
+ timer.unref();
446
+ };
447
+
448
+ /**
449
+ * Suspend drains and return an idempotent, token-scoped disposer.
450
+ * Each call allocates a fresh token; the returned disposer checks
451
+ * the token on every invocation so re-calling the same disposer is
452
+ * a guaranteed no-op. Critically, a *stale* disposer — one that
453
+ * survived its matching release via `withSuspendedDrains` — also
454
+ * cannot re-decrement the counter and mysteriously shift the idle
455
+ * window (round-9 MEDIUM). The `Math.max(0, …)` clamp is retained
456
+ * as defense-in-depth for the impossible case where two different
457
+ * disposers both land after cross-thread reordering.
458
+ */
459
+ const suspendDrains = (): (() => void) => {
460
+ cancelTimer();
461
+ loadCounter += 1;
462
+ const token = { disposed: false };
463
+ return (): void => {
464
+ if (token.disposed) return;
465
+ token.disposed = true;
466
+ loadCounter = Math.max(0, loadCounter - 1);
467
+ if (loadCounter === 0 && inFlight === 0) {
468
+ scheduleDrain();
469
+ }
470
+ };
471
+ };
472
+
473
+ /**
474
+ * Bracket `fn` with `suspendDrains()`. Works for both sync and
475
+ * async functions: the sync return path releases the suspend and
476
+ * returns `fn`'s value directly; the promise path releases the
477
+ * suspend once the promise settles. Either way, a throw / rejection
478
+ * releases the suspend before propagating.
479
+ *
480
+ * Round-10 MEDIUM hardening:
481
+ *
482
+ * - Thenable detection also admits `typeof === 'function'` so a
483
+ * callable that doubles as a thenable (awkward but legal per
484
+ * Promise/A+) is not treated as a synchronous return.
485
+ * - The `.then` property access is wrapped in try/catch so a
486
+ * throwing getter does not escape before `release()` runs; such
487
+ * a value is treated as non-thenable and the suspend releases
488
+ * synchronously.
489
+ * - Thenables are normalized via `Promise.resolve(result)` so a
490
+ * plain-object thenable that resolves without returning a Promise
491
+ * from `.then()` still produces a real `Promise<T>` for the
492
+ * caller (adopted via the Promise resolution procedure). A
493
+ * throw from inside `.then()` is captured by `Promise.resolve`
494
+ * and surfaces as a rejection, so the suspend still releases
495
+ * via `.finally()`.
496
+ */
497
+ function withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
498
+ function withSuspendedDrains<T>(fn: () => T): T;
499
+ function withSuspendedDrains<T>(fn: () => T | Promise<T>): T | Promise<T> {
500
+ const release = suspendDrains();
501
+ let released = false;
502
+ const releaseOnce = (): void => {
503
+ if (released) return;
504
+ released = true;
505
+ release();
506
+ };
507
+ let result: T | Promise<T>;
508
+ try {
509
+ result = fn();
510
+ } catch (err) {
511
+ releaseOnce();
512
+ throw err;
513
+ }
514
+ if (isThenable(result)) {
515
+ // `Promise.resolve` adopts any thenable via the Promise
516
+ // resolution procedure, so a non-Promise thenable (plain
517
+ // object or callable) still hands the caller back a real
518
+ // `Promise<T>`. `.finally` runs on both fulfil and reject.
519
+ return Promise.resolve(result).finally(releaseOnce) as Promise<T>;
520
+ }
521
+ releaseOnce();
522
+ return result as T;
523
+ }
524
+
525
+ return {
526
+ beginRequest(): void {
527
+ inFlight += 1;
528
+ cancelTimer();
529
+ },
530
+ endRequest(): void {
531
+ if (inFlight === 0) {
532
+ // Defensive: an `endRequest()` without a matching
533
+ // `beginRequest()` is a caller bug. Clamp at zero rather
534
+ // than letting the counter go negative and latch the drain
535
+ // off forever.
536
+ return;
537
+ }
538
+ inFlight -= 1;
539
+ if (inFlight === 0) {
540
+ scheduleDrain();
541
+ }
542
+ },
543
+ close(): void {
544
+ cancelTimer();
545
+ },
546
+ withSuspendedDrains,
547
+ suspendDrains,
548
+ get isPending(): boolean {
549
+ return timer !== null;
550
+ },
551
+ get inFlight(): number {
552
+ return inFlight;
553
+ },
554
+ };
555
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @mlx-node/server -- OpenAI Responses + Anthropic Messages server for MLX models.
3
+ *
4
+ * Exposes loaded models via `POST /v1/responses`, `POST /v1/messages`, and
5
+ * `GET /v1/models`, in both streaming (SSE) and non-streaming modes.
6
+ */
7
+
8
+ export { createServer, DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL } from './server.js';
9
+ export type { CloseOptions, CloseResult, ServerConfig, ServerInstance } from './server.js';
10
+
11
+ /**
12
+ * Readiness reporting. `deriveHealthStatus` is pure — a supervisor can reuse
13
+ * it to classify a `ServerHealth` body fetched over HTTP.
14
+ */
15
+ export { createHealthReporter, deriveHealthStatus, toMinimalHealth } from './health.js';
16
+ export type {
17
+ HealthReporterDeps,
18
+ HealthStatusInputs,
19
+ ModelLoadRecord,
20
+ ServerHealth,
21
+ ServerHealthMinimal,
22
+ ServerHealthStatus,
23
+ } from './health.js';
24
+
25
+ export { ModelWorkCoordinator } from './model-work-coordinator.js';
26
+ export type { ModelLoadOutcome } from './model-work-coordinator.js';
27
+ export type { GuardedLoadDeps, LoadModelOptions } from './load-model.js';
28
+
29
+ /**
30
+ * Internal helpers re-exported for unit testing only. Not part of the
31
+ * supported public API — names may change without notice.
32
+ */
33
+ export { parseEnvSeconds as __parseEnvSeconds, parseEnvPositiveInt as __parseEnvPositiveInt } from './server.js';
34
+ export {
35
+ createIdleSweeper as __createIdleSweeper,
36
+ parseIdleClearCacheEnv as __parseIdleClearCacheEnv,
37
+ DEFAULT_IDLE_CLEAR_CACHE_MS as __DEFAULT_IDLE_CLEAR_CACHE_MS,
38
+ } from './idle-sweeper.js';
39
+ export type { IdleSweeper } from './idle-sweeper.js';
40
+
41
+ export { createHandler } from './handler.js';
42
+ export type { HandlerOptions } from './handler.js';
43
+
44
+ export { ModelRegistry } from './registry.js';
45
+ export type { ServableModel, ModelEntry, ModelRegistryOptions, RegisterOptions } from './registry.js';
46
+
47
+ export { QueueFullError, SessionRegistry } from './session-registry.js';
48
+ export type { PreDispatchAdmission, SessionLookupResult, SessionRegistryOptions } from './session-registry.js';
49
+ export { resolveServerTuningForUsage } from './timing.js';
50
+ // NOTE: `__resetPromptCacheKeyNonceForTests` is intentionally NOT
51
+ // re-exported here. It is a test-only helper that nukes the module-
52
+ // scoped HMAC nonce (and the once-per-process single-tenant warning
53
+ // flag); exposing it on the public surface would let downstream
54
+ // consumers invalidate every live tier-2 entry with one call. Tests
55
+ // import it from the deep path
56
+ // `packages/server/src/session-registry.js` instead.
57
+
58
+ export type { PublicModelEntry } from './handler.js';
59
+
60
+ export type {
61
+ ResponsesAPIRequest,
62
+ ResponseObject,
63
+ ResponseUsage,
64
+ ResponseError,
65
+ InputItem,
66
+ InputMessage,
67
+ InputFunctionCall,
68
+ InputFunctionCallOutput,
69
+ OutputItem,
70
+ MessageOutputItem,
71
+ ReasoningOutputItem,
72
+ FunctionCallOutputItem,
73
+ OutputTextPart,
74
+ SummaryTextPart,
75
+ ResponsesToolDefinition,
76
+ ContentPart,
77
+ InputTextPart,
78
+ StreamEvent,
79
+ } from './types.js';
80
+
81
+ export type {
82
+ AnthropicCountTokensRequest,
83
+ AnthropicCountTokensResponse,
84
+ AnthropicMessagesRequest,
85
+ AnthropicMessagesResponse,
86
+ AnthropicMessage,
87
+ AnthropicContentBlock,
88
+ AnthropicTextContentBlock,
89
+ AnthropicImageContentBlock,
90
+ AnthropicToolResultContentBlock,
91
+ AnthropicToolUseContentBlock,
92
+ AnthropicThinkingContentBlock,
93
+ AnthropicToolDefinition,
94
+ AnthropicToolChoice,
95
+ AnthropicResponseContent,
96
+ AnthropicResponseTextBlock,
97
+ AnthropicResponseThinkingBlock,
98
+ AnthropicResponseToolUseBlock,
99
+ AnthropicUsage,
100
+ AnthropicStreamEvent,
101
+ AnthropicMessageStartEvent,
102
+ AnthropicContentBlockStartEvent,
103
+ AnthropicContentBlockDeltaEvent,
104
+ AnthropicContentBlockStopEvent,
105
+ AnthropicMessageDeltaEvent,
106
+ AnthropicMessageStopEvent,
107
+ AnthropicDelta,
108
+ AnthropicTextDelta,
109
+ AnthropicThinkingDelta,
110
+ AnthropicInputJsonDelta,
111
+ SystemBlock,
112
+ } from './types-anthropic.js';
113
+
114
+ export { writeSSEEvent, beginSSE, endSSE, activeSSEStreamCount } from './streaming.js';