@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.
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +68 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +20 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +572 -82
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +42 -0
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +6 -1
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +70 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +120 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +4 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +34 -4
- package/dist/server.d.ts +76 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +48 -1
- package/dist/session-registry.d.ts +272 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +509 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -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"}
|
package/dist/presets.js
ADDED
|
@@ -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
|
+
};
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/registry.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,yEAAyE;AACzE,MAAM,MAAM,aAAa,GAAG,mBAAmB,CAAC;AAEhD,0CAA0C;AAC1C,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,eAAe,EAAE,eAAe,CAAC;CAClC;AAsCD;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAoD;IAC7F;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,yDAAyD;IACzD,OAAO,CAAC,cAAc,CAAK;IAC3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAkF;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,yEAAyE;AACzE,MAAM,MAAM,aAAa,GAAG,mBAAmB,CAAC;AAEhD,0CAA0C;AAC1C,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,eAAe,EAAE,eAAe,CAAC;CAClC;AAsCD;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;;;;;;;;;OAeG;IACH,gBAAgB,CAAC,EAAE,UAAU,CAAC;IAC9B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAoD;IAC7F;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,yDAAyD;IACzD,OAAO,CAAC,cAAc,CAAK;IAC3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAkF;IAErH,YAAY,IAAI,CAAC,EAAE,oBAAoB,EAEtC;IAED;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,IAAI,CAwFzE;IAED;;;;;;;;;;OAUG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAMhC;IAED;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,iBAAiB;IAiBzB;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,uBAAuB;IAK/B;;;;;;;;;;;;;;;;;;OAkBG;IACH,oBAAoB,CAClB,IAAI,EAAE,MAAM,GACX;QAAE,KAAK,EAAE,aAAa,CAAC;QAAC,QAAQ,EAAE,eAAe,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CASrF;IAED;;;;;;;;OAQG;IACH,oBAAoB,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAQ/C;IAED;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,aAAa,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAIxC;IAED;;;;;;;;OAQG;IACH,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAQzC;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,+BAA+B,CAAC,KAAK,EAAE,aAAa,GAAG;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAUxF;IAED;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAO3C;IAED;;OAEG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAE3C;IAED;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAI9C;IAED;;;;;;;;;;;;;;OAcG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAE5D;IAED;;;;;;;;;OASG;IACH,qBAAqB,IAAI,eAAe,EAAE,CAMzC;IAED;;OAEG;IACH,IAAI,IAAI;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE,CAgB1E;IAED;;;;;;;;;OASG;IACH,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAG9C;CACF"}
|
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({
|
|
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
|
|
150
|
-
binding.pendingTeardown
|
|
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
|
package/dist/router.d.ts.map
CHANGED
|
@@ -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;
|
|
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`,
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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;
|
|
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, {
|
|
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
|
+
}
|