@mlx-node/server 0.0.7 → 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 +3 -3
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Sampling presets recommended by third-party model authors, exposed
3
+ * here as `ChatConfig`-shaped objects so an operator can pin them at
4
+ * `ModelRegistry.register(name, model, { samplingDefaults: ... })`
5
+ * time with a single import.
6
+ *
7
+ * Per-request client values (OpenAI `temperature`/`top_p`, Anthropic
8
+ * equivalents) still override these defaults where the client sends
9
+ * them — `ChatSession.mergeConfig` treats per-call config as an
10
+ * overlay on top of `defaultConfig`. These presets only fill in the
11
+ * parameters clients never send (`top_k`, `min_p`, penalties).
12
+ */
13
+ import type { ChatConfig } from '@mlx-node/core';
14
+ /**
15
+ * Sampling defaults from Unsloth's Qwen3.6 guide:
16
+ * https://unsloth.ai/docs/models/qwen3.6#recommended-settings
17
+ *
18
+ * All modes pin `top_k = 20` and `min_p = 0.0`; they differ in
19
+ * `temperature`, `top_p`, and `presence_penalty`.
20
+ *
21
+ * The native anti-repetition cutoff is now disabled by default
22
+ * (vLLM-aligned — vLLM ships no repetition-stop heuristic), so these
23
+ * presets no longer pin `maxConsecutiveTokens` / `maxNgramRepeats` /
24
+ * `ngramSize`. Repetition is shaped by the sampling penalties above and
25
+ * bounded by the per-model `maxOutputTokens`. An operator or client can
26
+ * still opt in by setting those fields explicitly — a per-request config
27
+ * value wins via `ChatSession.mergeConfig`.
28
+ */
29
+ export declare const QWEN_SAMPLING_DEFAULTS: {
30
+ /** Thinking mode for precise coding tasks: temp=0.6, top_p=0.95, pp=0.0 */
31
+ readonly thinkingCoding: {
32
+ temperature: number;
33
+ topP: number;
34
+ topK: number;
35
+ minP: number;
36
+ presencePenalty: number;
37
+ repetitionPenalty: number;
38
+ };
39
+ /** Thinking mode for general tasks: temp=1.0, top_p=0.95, pp=1.5 */
40
+ readonly thinkingGeneral: {
41
+ temperature: number;
42
+ topP: number;
43
+ topK: number;
44
+ minP: number;
45
+ presencePenalty: number;
46
+ repetitionPenalty: number;
47
+ };
48
+ /** Instruct (non-thinking) for general tasks: temp=0.7, top_p=0.8, pp=1.5 */
49
+ readonly instructGeneral: {
50
+ temperature: number;
51
+ topP: number;
52
+ topK: number;
53
+ minP: number;
54
+ presencePenalty: number;
55
+ repetitionPenalty: number;
56
+ };
57
+ /** Instruct (non-thinking) for reasoning tasks: temp=1.0, top_p=0.95, pp=1.5 */
58
+ readonly instructReasoning: {
59
+ temperature: number;
60
+ topP: number;
61
+ topK: number;
62
+ minP: number;
63
+ presencePenalty: number;
64
+ repetitionPenalty: number;
65
+ };
66
+ };
67
+ /** Sampling defaults for Gemma4 Instruct. */
68
+ export declare const GEMMA4_SAMPLING_DEFAULTS: ChatConfig;
69
+ /** Sampling defaults for LFM2.5 Thinking. */
70
+ export declare const LFM2_SAMPLING_DEFAULTS: ChatConfig;
71
+ /** Sampling + per-model output token cap exposed by {@link LAUNCH_PRESETS}. */
72
+ export interface LaunchPreset {
73
+ sampling: ChatConfig;
74
+ maxOutputTokens: number;
75
+ }
76
+ /**
77
+ * Per-`ModelType` presets used by `mlx launch claude` to pre-wire a
78
+ * discovered model with sensible sampling defaults + a max output
79
+ * token budget. Keyed on the string returned by `detectModelType()`.
80
+ */
81
+ export declare const LAUNCH_PRESETS: Record<string, LaunchPreset>;
82
+ //# sourceMappingURL=presets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,sBAAsB;IACjC,2EAA2E;;;;;;;;;IAU3E,oEAAoE;;;;;;;;;IAUpE,6EAA6E;;;;;;;;;IAU7E,gFAAgF;;;;;;;;;CASxE,CAAC;AAEX,6CAA6C;AAC7C,eAAO,MAAM,wBAAwB,EAAE,UAOtC,CAAC;AAEF,6CAA6C;AAC7C,eAAO,MAAM,sBAAsB,EAAE,UAOpC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,UAAU,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAqBvD,CAAC"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Sampling defaults from Unsloth's Qwen3.6 guide:
3
+ * https://unsloth.ai/docs/models/qwen3.6#recommended-settings
4
+ *
5
+ * All modes pin `top_k = 20` and `min_p = 0.0`; they differ in
6
+ * `temperature`, `top_p`, and `presence_penalty`.
7
+ *
8
+ * The native anti-repetition cutoff is now disabled by default
9
+ * (vLLM-aligned — vLLM ships no repetition-stop heuristic), so these
10
+ * presets no longer pin `maxConsecutiveTokens` / `maxNgramRepeats` /
11
+ * `ngramSize`. Repetition is shaped by the sampling penalties above and
12
+ * bounded by the per-model `maxOutputTokens`. An operator or client can
13
+ * still opt in by setting those fields explicitly — a per-request config
14
+ * value wins via `ChatSession.mergeConfig`.
15
+ */
16
+ export const QWEN_SAMPLING_DEFAULTS = {
17
+ /** Thinking mode for precise coding tasks: temp=0.6, top_p=0.95, pp=0.0 */
18
+ thinkingCoding: {
19
+ temperature: 0.6,
20
+ topP: 0.95,
21
+ topK: 20,
22
+ minP: 0.0,
23
+ presencePenalty: 0.0,
24
+ repetitionPenalty: 1.0,
25
+ },
26
+ /** Thinking mode for general tasks: temp=1.0, top_p=0.95, pp=1.5 */
27
+ thinkingGeneral: {
28
+ temperature: 1.0,
29
+ topP: 0.95,
30
+ topK: 20,
31
+ minP: 0.0,
32
+ presencePenalty: 1.5,
33
+ repetitionPenalty: 1.0,
34
+ },
35
+ /** Instruct (non-thinking) for general tasks: temp=0.7, top_p=0.8, pp=1.5 */
36
+ instructGeneral: {
37
+ temperature: 0.7,
38
+ topP: 0.8,
39
+ topK: 20,
40
+ minP: 0.0,
41
+ presencePenalty: 1.5,
42
+ repetitionPenalty: 1.0,
43
+ },
44
+ /** Instruct (non-thinking) for reasoning tasks: temp=1.0, top_p=0.95, pp=1.5 */
45
+ instructReasoning: {
46
+ temperature: 1.0,
47
+ topP: 0.95,
48
+ topK: 20,
49
+ minP: 0.0,
50
+ presencePenalty: 1.5,
51
+ repetitionPenalty: 1.0,
52
+ },
53
+ };
54
+ /** Sampling defaults for Gemma4 Instruct. */
55
+ export const GEMMA4_SAMPLING_DEFAULTS = {
56
+ temperature: 0.7,
57
+ topP: 0.95,
58
+ topK: 64,
59
+ minP: 0.0,
60
+ presencePenalty: 0.0,
61
+ repetitionPenalty: 1.0,
62
+ };
63
+ /** Sampling defaults for LFM2.5 Thinking. */
64
+ export const LFM2_SAMPLING_DEFAULTS = {
65
+ temperature: 0.05,
66
+ topP: 1.0,
67
+ topK: 50,
68
+ minP: 0.0,
69
+ presencePenalty: 0.0,
70
+ repetitionPenalty: 1.05,
71
+ };
72
+ /**
73
+ * Per-`ModelType` presets used by `mlx launch claude` to pre-wire a
74
+ * discovered model with sensible sampling defaults + a max output
75
+ * token budget. Keyed on the string returned by `detectModelType()`.
76
+ */
77
+ export const LAUNCH_PRESETS = {
78
+ qwen3: {
79
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
80
+ maxOutputTokens: 38912,
81
+ },
82
+ qwen3_5: {
83
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
84
+ maxOutputTokens: 81920,
85
+ },
86
+ qwen3_5_moe: {
87
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
88
+ maxOutputTokens: 81920,
89
+ },
90
+ gemma4: {
91
+ sampling: GEMMA4_SAMPLING_DEFAULTS,
92
+ maxOutputTokens: 16384,
93
+ },
94
+ lfm2: {
95
+ sampling: LFM2_SAMPLING_DEFAULTS,
96
+ maxOutputTokens: 8192,
97
+ },
98
+ };
@@ -36,6 +36,7 @@
36
36
  * `body.model`. Instance ids recognise them as the same binding
37
37
  * and the continuation is accepted.
38
38
  */
39
+ import type { ChatConfig } from '@mlx-node/core';
39
40
  import type { SessionCapableModel } from '@mlx-node/lm';
40
41
  import { SessionRegistry } from './session-registry.js';
41
42
  /** Minimal contract for a model that can be served via chat sessions. */
@@ -64,6 +65,35 @@ export interface ModelRegistryOptions {
64
65
  */
65
66
  maxQueueDepth?: number;
66
67
  }
68
+ /**
69
+ * Per-registration options for {@link ModelRegistry.register}.
70
+ */
71
+ export interface RegisterOptions {
72
+ /**
73
+ * Per-model sampling defaults forwarded through the bound
74
+ * `SessionRegistry` into every `ChatSession` it allocates (as the
75
+ * session's `defaultConfig`). Clients' per-request sampling values
76
+ * (OpenAI `temperature`/`top_p`, Anthropic equivalents) still win
77
+ * where present because `ChatSession.mergeConfig` treats them as an
78
+ * overlay — these defaults only fill the gaps for parameters the
79
+ * client never sent (`top_k`, `min_p`, penalties, etc.).
80
+ *
81
+ * Re-registering the same name with a fresh `samplingDefaults` value
82
+ * overwrites the binding's defaults in place so the next
83
+ * `ChatSession` allocated out of the registry picks up the new
84
+ * values. Warm sessions already in flight keep the previous defaults
85
+ * until they settle; this matches how the refresh path treats other
86
+ * per-binding state.
87
+ */
88
+ samplingDefaults?: ChatConfig;
89
+ /**
90
+ * Optional per-model upper bound for generated output tokens. This is
91
+ * intentionally separate from `samplingDefaults.maxNewTokens`: client
92
+ * requests still provide the desired length, while endpoint handlers can
93
+ * clamp pathological values before dispatch.
94
+ */
95
+ maxOutputTokens?: number;
96
+ }
67
97
  export declare class ModelRegistry {
68
98
  private readonly maxQueueDepth;
69
99
  private readonly models;
@@ -140,7 +170,7 @@ export declare class ModelRegistry {
140
170
  * preserves the id because `instanceIds.has(model)` is already
141
171
  * true.
142
172
  */
143
- register(name: string, model: ServableModel): void;
173
+ register(name: string, model: ServableModel, opts?: RegisterOptions): void;
144
174
  /**
145
175
  * Unregister a model by name.
146
176
  *
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
package/dist/registry.js CHANGED
@@ -115,13 +115,23 @@ export class ModelRegistry {
115
115
  * preserves the id because `instanceIds.has(model)` is already
116
116
  * true.
117
117
  */
118
- register(name, model) {
118
+ register(name, model, opts) {
119
+ const samplingDefaults = opts?.samplingDefaults;
120
+ const maxOutputTokens = opts?.maxOutputTokens;
119
121
  const existing = this.models.get(name);
120
122
  if (existing && existing.model === model) {
121
123
  // Same name + same model object: leave the binding and refcount
122
124
  // alone. Refresh createdAt so `/v1/models` surfaces the most
123
- // recent registration time.
125
+ // recent registration time. A fresh `samplingDefaults` from the
126
+ // re-registration is applied in place so the operator can tune
127
+ // per-model knobs without fully unregistering first.
124
128
  existing.createdAt = Math.floor(Date.now() / 1000);
129
+ if (opts && 'samplingDefaults' in opts) {
130
+ existing.sessionRegistry.setSamplingDefaults(samplingDefaults);
131
+ }
132
+ if (opts && 'maxOutputTokens' in opts) {
133
+ existing.sessionRegistry.setMaxOutputTokens(maxOutputTokens);
134
+ }
125
135
  return;
126
136
  }
127
137
  if (existing) {
@@ -138,7 +148,12 @@ export class ModelRegistry {
138
148
  let binding = this.sessionRegistriesByModel.get(model);
139
149
  if (!binding) {
140
150
  binding = {
141
- registry: new SessionRegistry({ model, maxQueueDepth: this.maxQueueDepth }),
151
+ registry: new SessionRegistry({
152
+ model,
153
+ maxQueueDepth: this.maxQueueDepth,
154
+ samplingDefaults,
155
+ maxOutputTokens,
156
+ }),
142
157
  refCount: 0,
143
158
  inFlight: 0,
144
159
  pendingPersists: 0,
@@ -146,8 +161,21 @@ export class ModelRegistry {
146
161
  };
147
162
  this.sessionRegistriesByModel.set(model, binding);
148
163
  }
149
- else if (binding.pendingTeardown) {
150
- binding.pendingTeardown = false;
164
+ else {
165
+ if (binding.pendingTeardown) {
166
+ binding.pendingTeardown = false;
167
+ }
168
+ // Aliasing or reviving an existing binding: if this call passed
169
+ // `samplingDefaults` explicitly, overwrite the shared binding's
170
+ // defaults so the latest registration wins for every alias.
171
+ // Call sites that omit the field leave the existing defaults
172
+ // intact.
173
+ if (opts && 'samplingDefaults' in opts) {
174
+ binding.registry.setSamplingDefaults(samplingDefaults);
175
+ }
176
+ if (opts && 'maxOutputTokens' in opts) {
177
+ binding.registry.setMaxOutputTokens(maxOutputTokens);
178
+ }
151
179
  }
152
180
  binding.refCount += 1;
153
181
  // Allocate a fresh monotonic instance id on first sight of this
package/dist/router.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Path-based router for /v1/* endpoints. */
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import type { ResponseStore } from '@mlx-node/core';
4
+ import type { PublicModelEntry } from './handler.js';
5
+ import type { IdleSweeper } from './idle-sweeper.js';
6
+ import type { ModelWorkCoordinator } from './model-work-coordinator.js';
4
7
  import type { ModelRegistry } from './registry.js';
5
- export declare function routeRequest(req: IncomingMessage, res: ServerResponse, registry: ModelRegistry, store: ResponseStore | null, responseRetentionSec?: number): Promise<void>;
8
+ export declare function routeRequest(req: IncomingMessage, res: ServerResponse, registry: ModelRegistry, store: ResponseStore | null, responseRetentionSec?: number, idleSweeper?: IdleSweeper | null, resolveModel?: (name: string) => Promise<void>, listModels?: () => PublicModelEntry[], modelWorkCoordinator?: ModelWorkCoordinator): Promise<void>;
6
9
  //# sourceMappingURL=router.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAE7C,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAyBnD,wBAAsB,YAAY,CAChC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GAAG,IAAI,EAC3B,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAAC,IAAI,CAAC,CA8Df"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAE7C,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAapD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAyBnD,wBAAsB,YAAY,CAChC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GAAG,IAAI,EAC3B,oBAAoB,CAAC,EAAE,MAAM,EAC7B,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,EAChC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,EAC9C,UAAU,CAAC,EAAE,MAAM,gBAAgB,EAAE,EACrC,oBAAoB,CAAC,EAAE,oBAAoB,GAC1C,OAAO,CAAC,IAAI,CAAC,CAwGf"}
package/dist/router.js CHANGED
@@ -1,4 +1,5 @@
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';
@@ -22,7 +23,7 @@ function readBody(req) {
22
23
  req.on('error', reject);
23
24
  });
24
25
  }
25
- export async function routeRequest(req, res, registry, store, responseRetentionSec) {
26
+ export async function routeRequest(req, res, registry, store, responseRetentionSec, idleSweeper, resolveModel, listModels, modelWorkCoordinator) {
26
27
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
27
28
  const path = url.pathname;
28
29
  if (path === '/v1/models') {
@@ -30,7 +31,7 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
30
31
  sendMethodNotAllowed(res, 'GET');
31
32
  return;
32
33
  }
33
- handleListModels(res, registry);
34
+ handleListModels(res, registry, listModels);
34
35
  return;
35
36
  }
36
37
  if (path === '/v1/responses') {
@@ -48,7 +49,25 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
48
49
  sendBadRequest(res, msg);
49
50
  return;
50
51
  }
51
- await handleCreateResponse(res, body, registry, store, req, responseRetentionSec);
52
+ await handleCreateResponse(res, body, registry, store, req, responseRetentionSec, idleSweeper, modelWorkCoordinator);
53
+ return;
54
+ }
55
+ if (path === '/v1/messages/count_tokens') {
56
+ if (req.method !== 'POST') {
57
+ sendAnthropicMethodNotAllowed(res, 'POST');
58
+ return;
59
+ }
60
+ let body;
61
+ try {
62
+ const raw = await readBody(req);
63
+ body = JSON.parse(raw);
64
+ }
65
+ catch (err) {
66
+ const msg = err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
67
+ sendAnthropicBadRequest(res, msg);
68
+ return;
69
+ }
70
+ await handleCountMessageTokens(res, body, registry, idleSweeper, resolveModel, modelWorkCoordinator);
52
71
  return;
53
72
  }
54
73
  if (path === '/v1/messages') {
@@ -66,7 +85,7 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
66
85
  sendAnthropicBadRequest(res, msg);
67
86
  return;
68
87
  }
69
- await handleCreateMessage(res, body, registry, req);
88
+ await handleCreateMessage(res, body, registry, req, idleSweeper, resolveModel, modelWorkCoordinator);
70
89
  return;
71
90
  }
72
91
  if (path === '/health' || path === '/v1/health') {
@@ -74,5 +93,16 @@ export async function routeRequest(req, res, registry, store, responseRetentionS
74
93
  res.end(JSON.stringify({ status: 'ok' }));
75
94
  return;
76
95
  }
96
+ // Liveness probe at `/`. Claude Code issues `HEAD /` before its first
97
+ // request; respond 200 so the probe doesn't leave a 404 in the logs.
98
+ if (path === '/') {
99
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
100
+ sendMethodNotAllowed(res, 'GET, HEAD');
101
+ return;
102
+ }
103
+ res.writeHead(200, { 'Content-Type': 'application/json' });
104
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify({ service: 'mlx-node' }));
105
+ return;
106
+ }
77
107
  sendNotFound(res, `No route matches ${req.method} ${path}`);
78
108
  }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  import { ModelRegistry } from './registry.js';
5
6
  /**
6
7
  * Parse a positive integer seconds value; returns undefined for unset/invalid so caller can apply its own default.
@@ -56,6 +57,36 @@ export interface ServerConfig {
56
57
  * `MLX_MAX_QUEUE_DEPTH_PER_MODEL` (positive integer).
57
58
  */
58
59
  maxQueueDepthPerModel?: number;
60
+ /**
61
+ * Milliseconds of HTTP inactivity (no request arrivals or completions)
62
+ * before the server issues a single `clearCache()` to drain the MLX
63
+ * Metal allocator's free pool. Replaces the old per-request
64
+ * `ClearCacheOnDrop` guard in the Rust layer, which was unsafe on a
65
+ * multi-model server because the pool is process-wide.
66
+ *
67
+ * Default: 30_000 ms (30 seconds). `0` disables the sweeper. Env
68
+ * override: `MLX_IDLE_CLEAR_CACHE_MS` (non-negative integer; 0
69
+ * disables; unset falls through to default).
70
+ *
71
+ * The decode-loop drain every 256 tokens inside each generative
72
+ * model is untouched and covers in-flight memory churn.
73
+ */
74
+ idleClearCacheMs?: number;
75
+ /**
76
+ * Optional async callback invoked before the endpoint layer looks
77
+ * the model up in the registry. Intended for lazy-load schemes:
78
+ * the callback should register the model into `registry` if it can;
79
+ * on return, the endpoint does `registry.get(body.model)` and 404s
80
+ * if still unresolved. Callback errors bubble up as 500s.
81
+ */
82
+ resolveModel?: (name: string) => Promise<void>;
83
+ /**
84
+ * Optional override for `GET /v1/models` enumeration. When provided,
85
+ * the endpoint returns this list instead of `registry.list()`.
86
+ * Intended for dynamic discovery schemes (e.g. enumerate every model
87
+ * on disk while only the currently-resident one is registered).
88
+ */
89
+ listModels?: () => PublicModelEntry[];
59
90
  }
60
91
  export interface ServerInstance {
61
92
  server: Server;
@@ -65,6 +96,51 @@ export interface ServerInstance {
65
96
  store: ResponseStore | null;
66
97
  /** Graceful shutdown. */
67
98
  close(): Promise<void>;
99
+ /**
100
+ * Run `fn` with the idle-drain timer suspended for the duration of
101
+ * an unbracketed, allocator-heavy operation — most commonly a hot
102
+ * `Model::load()` invoked AFTER the server has already served at
103
+ * least one request. In that scenario the post-request drain timer
104
+ * armed by `endRequest()` (at t+idleClearCacheMs) can otherwise
105
+ * fire MID-LOAD, racing the Metal allocator while weight
106
+ * materialization is still in progress.
107
+ *
108
+ * Handles try/finally bracketing so a thrown load never leaks the
109
+ * internal suspend counter. Accepts both sync and async functions;
110
+ * returns `fn`'s own return value (or resolved promise). When the
111
+ * bracket exits (normal or thrown) AND `inFlight === 0` with no
112
+ * other suspend active, a fresh drain timer is armed.
113
+ *
114
+ * Safe to nest — each call allocates its own token-scoped release
115
+ * so overlapping brackets unwind independently.
116
+ *
117
+ * The common `serve.ts` pattern (load all models BEFORE
118
+ * `createServer()`) does not need this API — there is no armed
119
+ * timer before the first request, so there is no race.
120
+ *
121
+ * Pass-through when the sweeper is disabled (`idleClearCacheMs: 0`
122
+ * or missing `__internal__.clearCache`): `fn` is invoked directly.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * await instance.withSuspendedDrains(async () => {
127
+ * const model = await Qwen35Model.load(modelPath);
128
+ * instance.registry.register('new-model', model);
129
+ * });
130
+ * ```
131
+ */
132
+ withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
133
+ withSuspendedDrains<T>(fn: () => T): T;
134
+ /**
135
+ * Low-level: suspend drains and return an idempotent, token-scoped
136
+ * disposer. Prefer {@link withSuspendedDrains} unless you need
137
+ * manual control over when the suspend is released. Safe to nest;
138
+ * calling the disposer more than once is a no-op.
139
+ *
140
+ * No-op when the sweeper is disabled (`idleClearCacheMs: 0` or
141
+ * missing `__internal__.clearCache`): returns a no-op disposer.
142
+ */
143
+ suspendDrains(): () => void;
68
144
  }
69
145
  /**
70
146
  * 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":"AAAA,0HAA0H;AAI1H,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAIxC,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIrD,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;IAC/B;;;;;;;;;;;;;OAaG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,EAAE,CAAC;CACvC;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;IACvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;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;;;;;;;;OAQG;IACH,aAAa,IAAI,MAAM,IAAI,CAAC;CAC7B;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CA8FjF"}
package/dist/server.js CHANGED
@@ -5,6 +5,8 @@ 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 { createIdleSweeper, DEFAULT_IDLE_CLEAR_CACHE_MS, parseIdleClearCacheEnv } from './idle-sweeper.js';
9
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
8
10
  import { ModelRegistry } from './registry.js';
9
11
  /** Cleanup interval for expired responses (ms). */
10
12
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -108,7 +110,18 @@ export async function createServer(config) {
108
110
  // allocated on `register()`) all share a single effective value.
109
111
  const configMaxQueueDepth = normalizePositiveIntConfig(config?.maxQueueDepthPerModel, 'maxQueueDepthPerModel');
110
112
  const maxQueueDepthPerModel = configMaxQueueDepth ?? parseEnvPositiveInt('MLX_MAX_QUEUE_DEPTH_PER_MODEL');
113
+ // Idle sweeper wiring. `0` is a legal explicit "off" value so we
114
+ // cannot reuse `normalizePositiveIntConfig` (which rejects 0).
115
+ // Precedence: explicit config value wins over env wins over default.
116
+ const idleClearCacheMs = resolveIdleClearCacheMs(config?.idleClearCacheMs);
117
+ const idleSweeper = createIdleSweeper(idleClearCacheMs);
118
+ // The sweeper is driven exclusively by `endRequest()` calls in the
119
+ // inference endpoints (`/v1/responses`, `/v1/messages`). There is no
120
+ // cold-start drain: a process that loads models but never receives a
121
+ // request will not fire `clearCache()`. See the `idle-sweeper.ts`
122
+ // module doc (section "Drain is post-request only") for rationale.
111
123
  const registry = new ModelRegistry({ maxQueueDepth: maxQueueDepthPerModel });
124
+ const modelWorkCoordinator = new ModelWorkCoordinator();
112
125
  let store = null;
113
126
  if (!disableStore) {
114
127
  const storePath = config?.storePath ?? join(homedir(), '.mlx-node', 'responses.db');
@@ -126,7 +139,15 @@ export async function createServer(config) {
126
139
  }
127
140
  }, CLEANUP_INTERVAL_MS);
128
141
  cleanupTimer.unref();
129
- const handler = createHandler(registry, { cors, store, responseRetentionSec });
142
+ const handler = createHandler(registry, {
143
+ cors,
144
+ store,
145
+ responseRetentionSec,
146
+ idleSweeper,
147
+ resolveModel: config?.resolveModel,
148
+ modelWorkCoordinator,
149
+ listModels: config?.listModels,
150
+ });
130
151
  const server = httpCreateServer(handler);
131
152
  await new Promise((resolve, reject) => {
132
153
  const onError = (err) => {
@@ -145,6 +166,7 @@ export async function createServer(config) {
145
166
  store,
146
167
  async close() {
147
168
  clearInterval(cleanupTimer);
169
+ idleSweeper.close();
148
170
  await new Promise((resolve, reject) => {
149
171
  server.close((err) => {
150
172
  if (err)
@@ -154,5 +176,30 @@ export async function createServer(config) {
154
176
  });
155
177
  });
156
178
  },
179
+ withSuspendedDrains(fn) {
180
+ return idleSweeper.withSuspendedDrains(fn);
181
+ },
182
+ suspendDrains() {
183
+ return idleSweeper.suspendDrains();
184
+ },
157
185
  };
158
186
  }
187
+ /**
188
+ * Resolve the effective idle-drain delay from (constructor value, env,
189
+ * default). Unlike the other knobs, `0` is a legal explicit opt-out —
190
+ * we must NOT fall through to env/default when the caller explicitly
191
+ * passes `0`. Returns a non-negative integer milliseconds value.
192
+ */
193
+ function resolveIdleClearCacheMs(configValue) {
194
+ if (configValue !== undefined) {
195
+ if (typeof configValue !== 'number' ||
196
+ !Number.isFinite(configValue) ||
197
+ !Number.isInteger(configValue) ||
198
+ configValue < 0) {
199
+ throw new Error(`idleClearCacheMs must be a non-negative integer; received ${String(configValue)}`);
200
+ }
201
+ return configValue;
202
+ }
203
+ const envValue = parseIdleClearCacheEnv();
204
+ return envValue ?? DEFAULT_IDLE_CLEAR_CACHE_MS;
205
+ }