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