@mlx-node/server 0.0.0 → 0.0.8

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 (74) hide show
  1. package/dist/chat-session-warm-reuse.d.ts +51 -0
  2. package/dist/chat-session-warm-reuse.d.ts.map +1 -0
  3. package/dist/chat-session-warm-reuse.js +68 -0
  4. package/dist/endpoints/messages-count-tokens.d.ts +8 -0
  5. package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
  6. package/dist/endpoints/messages-count-tokens.js +121 -0
  7. package/dist/endpoints/messages.d.ts +57 -5
  8. package/dist/endpoints/messages.d.ts.map +1 -1
  9. package/dist/endpoints/messages.js +1043 -147
  10. package/dist/endpoints/models.d.ts +2 -1
  11. package/dist/endpoints/models.d.ts.map +1 -1
  12. package/dist/endpoints/models.js +2 -2
  13. package/dist/endpoints/responses.d.ts +20 -7
  14. package/dist/endpoints/responses.d.ts.map +1 -1
  15. package/dist/endpoints/responses.js +572 -82
  16. package/dist/errors.d.ts +1 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +3 -0
  19. package/dist/handler.d.ts +42 -0
  20. package/dist/handler.d.ts.map +1 -1
  21. package/dist/handler.js +6 -1
  22. package/dist/idle-sweeper.d.ts +245 -0
  23. package/dist/idle-sweeper.d.ts.map +1 -0
  24. package/dist/idle-sweeper.js +408 -0
  25. package/dist/index.d.ts +8 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/mappers/anthropic-request.d.ts +24 -2
  29. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  30. package/dist/mappers/anthropic-request.js +222 -24
  31. package/dist/mappers/anthropic-response.d.ts +29 -4
  32. package/dist/mappers/anthropic-response.d.ts.map +1 -1
  33. package/dist/mappers/anthropic-response.js +143 -21
  34. package/dist/mappers/request.d.ts +48 -0
  35. package/dist/mappers/request.d.ts.map +1 -1
  36. package/dist/mappers/request.js +211 -35
  37. package/dist/mappers/response.d.ts.map +1 -1
  38. package/dist/mappers/response.js +13 -1
  39. package/dist/model-work-coordinator.d.ts +70 -0
  40. package/dist/model-work-coordinator.d.ts.map +1 -0
  41. package/dist/model-work-coordinator.js +120 -0
  42. package/dist/pending-writes.d.ts.map +1 -1
  43. package/dist/presets.d.ts +82 -0
  44. package/dist/presets.d.ts.map +1 -0
  45. package/dist/presets.js +98 -0
  46. package/dist/registry.d.ts +31 -1
  47. package/dist/registry.d.ts.map +1 -1
  48. package/dist/registry.js +33 -5
  49. package/dist/router.d.ts +4 -1
  50. package/dist/router.d.ts.map +1 -1
  51. package/dist/router.js +34 -4
  52. package/dist/server.d.ts +76 -0
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +48 -1
  55. package/dist/session-registry.d.ts +272 -18
  56. package/dist/session-registry.d.ts.map +1 -1
  57. package/dist/session-registry.js +509 -37
  58. package/dist/stop-sequence-buffer.d.ts +58 -0
  59. package/dist/stop-sequence-buffer.d.ts.map +1 -0
  60. package/dist/stop-sequence-buffer.js +148 -0
  61. package/dist/text-recovery.d.ts +35 -0
  62. package/dist/text-recovery.d.ts.map +1 -0
  63. package/dist/text-recovery.js +41 -0
  64. package/dist/timing.d.ts +80 -0
  65. package/dist/timing.d.ts.map +1 -0
  66. package/dist/timing.js +121 -0
  67. package/dist/tool-call-buffer.d.ts +5 -5
  68. package/dist/tool-call-buffer.d.ts.map +1 -1
  69. package/dist/tool-call-buffer.js +28 -8
  70. package/dist/types-anthropic.d.ts +161 -1
  71. package/dist/types-anthropic.d.ts.map +1 -1
  72. package/dist/types.d.ts +172 -2
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +5 -5
package/dist/errors.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare function sendAnthropicError(res: ServerResponse, status: number,
32
32
  export declare function sendAnthropicBadRequest(res: ServerResponse, message: string): void;
33
33
  export declare function sendAnthropicNotFound(res: ServerResponse, message: string): void;
34
34
  export declare function sendAnthropicInternalError(res: ServerResponse, message: string): void;
35
+ export declare function sendAnthropicNotImplemented(res: ServerResponse, message: string): void;
35
36
  export declare function sendAnthropicMethodNotAllowed(res: ServerResponse, allowed: string): void;
36
37
  /**
37
38
  * 429 Anthropic-shape rate-limit response. Mirror of {@link sendRateLimit}
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAgB,SAAS,CACvB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACpB,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAEzF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEvE;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAO/E;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAE5E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7E;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAYxE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAG3G;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAElF;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEhF;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAErF;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAGxF;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAGjF"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAgB,SAAS,CACvB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACpB,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAEzF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEvE;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAO/E;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAE5E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7E;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAYxE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAG3G;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAElF;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEhF;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAErF;AAED,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEtF;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAGxF;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAGjF"}
package/dist/errors.js CHANGED
@@ -68,6 +68,9 @@ export function sendAnthropicNotFound(res, message) {
68
68
  export function sendAnthropicInternalError(res, message) {
69
69
  sendAnthropicError(res, 500, 'api_error', message);
70
70
  }
71
+ export function sendAnthropicNotImplemented(res, message) {
72
+ sendAnthropicError(res, 501, 'not_supported_error', message);
73
+ }
71
74
  export function sendAnthropicMethodNotAllowed(res, allowed) {
72
75
  res.writeHead(405, { Allow: allowed, 'Content-Type': 'application/json' });
73
76
  res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'Method not allowed' } }));
package/dist/handler.d.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  /** Composable `(req, res)` handler for node:http — usable standalone or mounted into an existing server. */
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import type { ResponseStore } from '@mlx-node/core';
4
+ import type { IdleSweeper } from './idle-sweeper.js';
5
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
4
6
  import type { ModelRegistry } from './registry.js';
7
+ /**
8
+ * Entry in the list returned by `GET /v1/models`. Matches the shape
9
+ * `ModelRegistry.list()` produces — exported so callers that supply a
10
+ * custom `listModels` callback (e.g. `mlx launch claude` discovering
11
+ * every model on disk) can build entries without importing internals.
12
+ */
13
+ export interface PublicModelEntry {
14
+ id: string;
15
+ object: 'model';
16
+ created: number;
17
+ owned_by: string;
18
+ }
5
19
  export interface HandlerOptions {
6
20
  /** Enable CORS headers (default: true). */
7
21
  cors?: boolean;
@@ -13,6 +27,34 @@ export interface HandlerOptions {
13
27
  * and `ServerConfig.responseRetentionSec`).
14
28
  */
15
29
  responseRetentionSec?: number;
30
+ /**
31
+ * Optional idle sweeper. Forwarded to `routeRequest` so that the
32
+ * inference endpoints (`/v1/responses` + `/v1/messages`) can bracket
33
+ * their native-model dispatch with `beginRequest()` / `endRequest()`.
34
+ *
35
+ * Note: the begin/end hooks are DELIBERATELY scoped to the inference
36
+ * endpoints — wrapping every HTTP call (OPTIONS preflights,
37
+ * `/v1/models`, `/v1/health`, 404s) would let purely observational
38
+ * traffic keep the allocator pinned forever.
39
+ */
40
+ idleSweeper?: IdleSweeper | null;
41
+ /**
42
+ * Optional async callback invoked by `/v1/messages` before it looks
43
+ * the model up in the registry. The callback should register the
44
+ * model on demand; on return, the endpoint does `registry.get(name)`
45
+ * and 404s if still unresolved.
46
+ */
47
+ resolveModel?: (name: string) => Promise<void>;
48
+ /**
49
+ * Coordinates process-wide MLX work so lazy model loads / warmups do not
50
+ * overlap live inference on another model.
51
+ */
52
+ modelWorkCoordinator?: ModelWorkCoordinator;
53
+ /**
54
+ * Optional override for `GET /v1/models`. When provided, that endpoint
55
+ * returns this list instead of `registry.list()`.
56
+ */
57
+ listModels?: () => PublicModelEntry[];
16
58
  }
17
59
  export declare function createHandler(registry: ModelRegistry, options?: HandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
18
60
  //# sourceMappingURL=handler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,4GAA4G;AAE5G,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAGnD,MAAM,WAAW,cAAc;IAC7B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,KAAK,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,cAAc,GACvB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAgC9D"}
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,4GAA4G;AAE5G,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAGpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAGnD;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,KAAK,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,EAAE,CAAC;CACvC;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,cAAc,GACvB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CA8C9D"}
package/dist/handler.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /** Composable `(req, res)` handler for node:http — usable standalone or mounted into an existing server. */
2
2
  import { sendInternalError } from './errors.js';
3
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
3
4
  import { routeRequest } from './router.js';
4
5
  export function createHandler(registry, options) {
5
6
  const cors = options?.cors ?? true;
6
7
  const store = options?.store ?? null;
7
8
  const responseRetentionSec = options?.responseRetentionSec;
9
+ const idleSweeper = options?.idleSweeper ?? null;
10
+ const resolveModel = options?.resolveModel;
11
+ const modelWorkCoordinator = options?.modelWorkCoordinator ?? (resolveModel ? new ModelWorkCoordinator() : undefined);
12
+ const listModels = options?.listModels;
8
13
  return async (req, res) => {
9
14
  if (cors) {
10
15
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -20,7 +25,7 @@ export function createHandler(registry, options) {
20
25
  // post-`res.end()` bookkeeping (e.g. `SessionRegistry.adopt`). `http.createServer`
21
26
  // ignores the return value, so this is transparent to production callers.
22
27
  try {
23
- await routeRequest(req, res, registry, store, responseRetentionSec);
28
+ await routeRequest(req, res, registry, store, responseRetentionSec, idleSweeper, resolveModel, listModels, modelWorkCoordinator);
24
29
  }
25
30
  catch (err) {
26
31
  const message = err instanceof Error ? err.message : 'Internal server error';
@@ -0,0 +1,245 @@
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
+ /** Default idle window before draining the allocator's free pool (ms). */
114
+ export declare const DEFAULT_IDLE_CLEAR_CACHE_MS = 30000;
115
+ /**
116
+ * Parse `MLX_IDLE_CLEAR_CACHE_MS`. Same semantics as the other env
117
+ * knobs in `server.ts`: finite non-negative integer or fall through
118
+ * to the caller's default. A value of `0` explicitly disables the
119
+ * sweeper; negative / non-integer / unparseable values are ignored.
120
+ */
121
+ export declare function parseIdleClearCacheEnv(): number | undefined;
122
+ /**
123
+ * In-flight counter-based idle-drain scheduler.
124
+ *
125
+ * - `beginRequest()` is called ONCE per inference request BEFORE the
126
+ * native model is dispatched. It increments the in-flight counter
127
+ * and cancels any pending drain timer — eliminating the mid-request
128
+ * drain race the debounced-`touch()` design had. Only the inference
129
+ * endpoints (`/v1/responses` + `/v1/messages`) should call this;
130
+ * `/v1/models`, `/v1/health`, and CORS preflights MUST NOT, or they
131
+ * would keep the allocator pinned forever on purely observational
132
+ * traffic.
133
+ * - `endRequest()` is called ONCE per request in a `finally` block
134
+ * after the model stream has fully ended (covering success, error,
135
+ * and client-abort paths). It decrements the counter; when the
136
+ * counter reaches zero it arms a `delayMs` timer that calls
137
+ * `onDrain()` on expiry. This is the ONLY path that ever schedules
138
+ * a drain — see the module-level "Drain is post-request only"
139
+ * note for why cold-start arming was removed.
140
+ * - `close()` cancels any pending drain — used during graceful
141
+ * shutdown so the timer does not keep Node alive after
142
+ * `server.close()`. It intentionally does NOT reset the counter:
143
+ * draining partway through in-flight requests is the exact failure
144
+ * mode we're avoiding.
145
+ *
146
+ * Thread-safety is provided by Node's single-threaded event loop —
147
+ * `beginRequest` / `endRequest` are only ever invoked from the HTTP
148
+ * handler (or tests). Every `beginRequest()` MUST be paired with
149
+ * exactly one `endRequest()` on every exit path — a missed
150
+ * `endRequest()` would leave the counter pinned above zero and the
151
+ * drain would never fire.
152
+ */
153
+ export interface IdleSweeper {
154
+ /** Mark a request as arrived. Cancels any pending drain. */
155
+ beginRequest(): void;
156
+ /**
157
+ * Mark a request as completed. When the in-flight counter reaches
158
+ * zero, arms a drain timer for `delayMs`.
159
+ */
160
+ endRequest(): void;
161
+ /** Cancel the pending drain. Idempotent. Does NOT reset the counter. */
162
+ close(): void;
163
+ /**
164
+ * Run `fn` with drains suspended. Handles try/finally bracketing so
165
+ * a thrown load never leaks the internal suspend counter. This is
166
+ * the canonical entry point for hot-load flows — a `Model::load()`
167
+ * invoked AFTER the server has already served at least one request.
168
+ * In that scenario the post-request drain timer armed by
169
+ * `endRequest()` (t+delayMs) can otherwise fire MID-LOAD while
170
+ * weight materialization is still allocating through the Metal free
171
+ * pool, racing the allocator state.
172
+ *
173
+ * Accepts both sync and async functions; returns `fn`'s own return
174
+ * value (or resolved promise). On exit (normal or thrown), the
175
+ * suspend token is disposed exactly once — the drain timer is
176
+ * re-armed if `inFlight === 0` and no other suspend is active.
177
+ *
178
+ * Thenable-safe: non-Promise thenables (including function-typed
179
+ * values that expose a `.then` method) are normalized via
180
+ * `Promise.resolve()` so callers always receive a real `Promise<T>`
181
+ * on the async branch; a throwing `.then` getter is guarded and the
182
+ * value is treated as non-thenable rather than leaking the suspend.
183
+ *
184
+ * The common `serve.ts` pattern — load all models before
185
+ * `createServer(...)`, i.e. before any request is served — has no
186
+ * armed timer in the first place and therefore does NOT need to
187
+ * bracket.
188
+ *
189
+ * Pass-through on the disabled sweeper (`delayMs <= 0`): `fn` is
190
+ * invoked directly and its return value is returned unchanged, so
191
+ * call sites can unconditionally bracket without branching.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * await server.withSuspendedDrains(async () => {
196
+ * const model = await Qwen35MoeModel.load(modelPath);
197
+ * server.registry.register('qwen', model);
198
+ * });
199
+ * ```
200
+ */
201
+ withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
202
+ withSuspendedDrains<T>(fn: () => T): T;
203
+ /**
204
+ * Low-level: suspend drains and return an idempotent disposer.
205
+ * Prefer {@link withSuspendedDrains} unless you need manual
206
+ * control over when the suspend is released (for example across
207
+ * async boundaries the caller wants to manage explicitly).
208
+ *
209
+ * Semantics: cancels any pending drain and increments an internal
210
+ * "load" counter. Returns a disposer — calling the disposer
211
+ * decrements the counter. When the counter reaches zero AND
212
+ * `inFlight === 0`, a fresh drain timer is armed so a subsequent
213
+ * idle window still drains.
214
+ *
215
+ * Safe to nest: N suspends require N dispose calls. The returned
216
+ * disposer is token-scoped and idempotent — calling it more than
217
+ * once is a no-op, so it cannot over-decrement and mysteriously
218
+ * shift the idle window.
219
+ *
220
+ * No-op on the disabled sweeper (`delayMs <= 0`): returns a no-op
221
+ * disposer so call sites can unconditionally bracket without
222
+ * branching on whether the sweeper is enabled.
223
+ */
224
+ suspendDrains(): () => void;
225
+ /** Observability hook — `true` while a drain is scheduled. */
226
+ readonly isPending: boolean;
227
+ /** Observability hook — current in-flight request count. */
228
+ readonly inFlight: number;
229
+ }
230
+ /**
231
+ * Create an idle sweeper. Pass `0` or a non-positive value to opt out —
232
+ * the returned object becomes a no-op that still satisfies the
233
+ * interface. Callers can therefore unconditionally wire
234
+ * `beginRequest()` / `endRequest()` without branching on whether the
235
+ * sweeper is enabled.
236
+ *
237
+ * When `onDrain` is omitted, the sweeper resolves
238
+ * `__internal__.clearCache` on `@mlx-node/core` at creation time and
239
+ * caches the result in the returned closure. A missing namespace /
240
+ * function triggers a one-time `console.warn` and a no-op fallback —
241
+ * see `resolveClearCache()`. The `delayMs <= 0` path skips the
242
+ * resolution entirely so the opt-out remains purely passive.
243
+ */
244
+ export declare function createIdleSweeper(delayMs: number, onDrain?: () => void): IdleSweeper;
245
+ //# sourceMappingURL=idle-sweeper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idle-sweeper.d.ts","sourceRoot":"","sources":["../src/idle-sweeper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+GG;AAsBH,0EAA0E;AAC1E,eAAO,MAAM,2BAA2B,QAAS,CAAC;AA4BlD;;;;;GAKG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,SAAS,CAO3D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,YAAY,IAAI,IAAI,CAAC;IACrB;;;OAGG;IACH,UAAU,IAAI,IAAI,CAAC;IACnB,wEAAwE;IACxE,KAAK,IAAI,IAAI,CAAC;IACd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAqCG;IACH,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACzD,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IACvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,aAAa,IAAI,MAAM,IAAI,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,WAAW,CAoNpF"}