@mlx-node/server 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** OpenAI- and Anthropic-compatible JSON error responses. */
|
|
2
|
+
|
|
3
|
+
import type { ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
export interface APIError {
|
|
6
|
+
type: string;
|
|
7
|
+
message: string;
|
|
8
|
+
code: string | null;
|
|
9
|
+
param: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sendError(
|
|
13
|
+
res: ServerResponse,
|
|
14
|
+
status: number,
|
|
15
|
+
type: string,
|
|
16
|
+
message: string,
|
|
17
|
+
param?: string | null,
|
|
18
|
+
): void {
|
|
19
|
+
const body: { error: APIError } = {
|
|
20
|
+
error: {
|
|
21
|
+
type,
|
|
22
|
+
message,
|
|
23
|
+
code: null,
|
|
24
|
+
param: param ?? null,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sendBadRequest(res: ServerResponse, message: string, param?: string): void {
|
|
32
|
+
sendError(res, 400, 'invalid_request_error', message, param);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sendNotFound(res: ServerResponse, message: string): void {
|
|
36
|
+
sendError(res, 404, 'not_found_error', message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sendMethodNotAllowed(res: ServerResponse, allowed: string): void {
|
|
40
|
+
res.writeHead(405, { Allow: allowed, 'Content-Type': 'application/json' });
|
|
41
|
+
res.end(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
error: { type: 'invalid_request_error', message: 'Method not allowed', code: null, param: null },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sendInternalError(res: ServerResponse, message: string): void {
|
|
49
|
+
sendError(res, 500, 'server_error', message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 503 with `type: 'storage_timeout'`. Emitted by the responses endpoint when
|
|
54
|
+
* an in-flight `store.store(...)` gating a `previous_response_id` continuation
|
|
55
|
+
* fails to settle within `CHAIN_WRITE_WAIT_TIMEOUT_MS` and the final `getChain`
|
|
56
|
+
* probe still misses. 503 (not 404) because the write may yet land, so the
|
|
57
|
+
* same id can be retried — a 404 would wrongly mark it permanently invalid.
|
|
58
|
+
*/
|
|
59
|
+
export function sendStorageTimeout(res: ServerResponse, message: string): void {
|
|
60
|
+
sendError(res, 503, 'storage_timeout', message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 429 with `type: 'rate_limit_error'` and `code: 'queue_full'`. Emitted by
|
|
65
|
+
* `/v1/responses` when the per-model execution queue is already holding
|
|
66
|
+
* `maxQueueDepth` waiters behind the current dispatch. Always sets
|
|
67
|
+
* `Retry-After: 1` (string seconds) so clients back off briefly before
|
|
68
|
+
* retrying — short enough to encourage a retry, long enough to avoid
|
|
69
|
+
* busy-looping the server.
|
|
70
|
+
*/
|
|
71
|
+
export function sendRateLimit(res: ServerResponse, message: string): void {
|
|
72
|
+
res.writeHead(429, { 'Retry-After': '1', 'Content-Type': 'application/json' });
|
|
73
|
+
res.end(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
error: {
|
|
76
|
+
type: 'rate_limit_error',
|
|
77
|
+
message,
|
|
78
|
+
code: 'queue_full',
|
|
79
|
+
param: null,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sendAnthropicError(res: ServerResponse, status: number, type: string, message: string): void {
|
|
86
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
87
|
+
res.end(JSON.stringify({ type: 'error', error: { type, message } }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sendAnthropicBadRequest(res: ServerResponse, message: string): void {
|
|
91
|
+
sendAnthropicError(res, 400, 'invalid_request_error', message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function sendAnthropicNotFound(res: ServerResponse, message: string): void {
|
|
95
|
+
sendAnthropicError(res, 404, 'not_found_error', message);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function sendAnthropicInternalError(res: ServerResponse, message: string): void {
|
|
99
|
+
sendAnthropicError(res, 500, 'api_error', message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function sendAnthropicNotImplemented(res: ServerResponse, message: string): void {
|
|
103
|
+
sendAnthropicError(res, 501, 'not_supported_error', message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sendAnthropicMethodNotAllowed(res: ServerResponse, allowed: string): void {
|
|
107
|
+
res.writeHead(405, { Allow: allowed, 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'Method not allowed' } }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 429 Anthropic-shape rate-limit response. Mirror of {@link sendRateLimit}
|
|
113
|
+
* for `/v1/messages`. Body uses the `{ type: 'error', error: { type, message } }`
|
|
114
|
+
* envelope the rest of the Anthropic error helpers use; `Retry-After: 1`
|
|
115
|
+
* is set verbatim so clients can wait one second before retrying.
|
|
116
|
+
*/
|
|
117
|
+
export function sendAnthropicRateLimit(res: ServerResponse, message: string): void {
|
|
118
|
+
res.writeHead(429, { 'Retry-After': '1', 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message } }));
|
|
120
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Composable `(req, res)` handler for node:http — usable standalone or mounted into an existing server. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
import type { ResponseStore } from '@mlx-node/core';
|
|
6
|
+
|
|
7
|
+
import { hasCredential, isAuthorized, sendUnauthorized } from './auth.js';
|
|
8
|
+
import { sendInternalError } from './errors.js';
|
|
9
|
+
import { createHealthReporter, type ServerHealth } from './health.js';
|
|
10
|
+
import type { IdleSweeper } from './idle-sweeper.js';
|
|
11
|
+
import { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
12
|
+
import type { ModelRegistry } from './registry.js';
|
|
13
|
+
import { requestPathname, routeRequest } from './router.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Routes reachable WITHOUT a token when one is configured.
|
|
17
|
+
*
|
|
18
|
+
* A supervisor has to poll liveness before it can be handed a token (it may
|
|
19
|
+
* be the thing that generates it). Everything served here is scrubbed to
|
|
20
|
+
* `{ status, uptimeMs, pid }` by the router when the caller is
|
|
21
|
+
* unauthenticated — see `toMinimalHealth`.
|
|
22
|
+
*
|
|
23
|
+
* `/` is included because it is a pure liveness stub: the router answers it
|
|
24
|
+
* with a constant `{ service: 'mlx-node' }` and reads no state, so there is
|
|
25
|
+
* nothing to leak. Claude Code issues `HEAD /` before its first request — it
|
|
26
|
+
* does send `x-api-key`, so it would pass the check anyway, but gating a
|
|
27
|
+
* contentless probe would 401 every other client's liveness check for no
|
|
28
|
+
* security gain.
|
|
29
|
+
*/
|
|
30
|
+
const UNAUTHENTICATED_PATHS = new Set(['/', '/health', '/v1/health']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Entry in the list returned by `GET /v1/models`. Matches the shape
|
|
34
|
+
* `ModelRegistry.list()` produces — exported so callers that supply a
|
|
35
|
+
* custom `listModels` callback (e.g. `mlx launch claude` discovering
|
|
36
|
+
* every model on disk) can build entries without importing internals.
|
|
37
|
+
*/
|
|
38
|
+
export interface PublicModelEntry {
|
|
39
|
+
id: string;
|
|
40
|
+
object: 'model';
|
|
41
|
+
created: number;
|
|
42
|
+
owned_by: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HandlerOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Enable CORS headers.
|
|
48
|
+
*
|
|
49
|
+
* Default: `true` when no `authToken` is set (historical behaviour), and
|
|
50
|
+
* `false` once one is — `Access-Control-Allow-Origin: *` on a
|
|
51
|
+
* token-protected server would invite any web page to spend a leaked token
|
|
52
|
+
* from the user's browser. An explicit value always wins.
|
|
53
|
+
*/
|
|
54
|
+
cors?: boolean;
|
|
55
|
+
/** Response store for previous_response_id support. */
|
|
56
|
+
store?: ResponseStore | null;
|
|
57
|
+
/**
|
|
58
|
+
* Retention (seconds) stamped as `expires_at` when committing a response row.
|
|
59
|
+
* When omitted, the endpoint falls back to its own default (see `responses.ts`
|
|
60
|
+
* and `ServerConfig.responseRetentionSec`).
|
|
61
|
+
*/
|
|
62
|
+
responseRetentionSec?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Optional idle sweeper. Forwarded to `routeRequest` so that the
|
|
65
|
+
* inference endpoints (`/v1/responses` + `/v1/messages`) can bracket
|
|
66
|
+
* their native-model dispatch with `beginRequest()` / `endRequest()`.
|
|
67
|
+
*
|
|
68
|
+
* Note: the begin/end hooks are DELIBERATELY scoped to the inference
|
|
69
|
+
* endpoints — wrapping every HTTP call (OPTIONS preflights,
|
|
70
|
+
* `/v1/models`, `/v1/health`, 404s) would let purely observational
|
|
71
|
+
* traffic keep the allocator pinned forever.
|
|
72
|
+
*/
|
|
73
|
+
idleSweeper?: IdleSweeper | null;
|
|
74
|
+
/**
|
|
75
|
+
* Optional async callback invoked by `/v1/messages` before it looks
|
|
76
|
+
* the model up in the registry. The callback should register the
|
|
77
|
+
* model on demand; on return, the endpoint does `registry.get(name)`
|
|
78
|
+
* and 404s if still unresolved.
|
|
79
|
+
*/
|
|
80
|
+
resolveModel?: (name: string) => Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Coordinates process-wide MLX work so lazy model loads / warmups do not
|
|
83
|
+
* overlap live inference on another model.
|
|
84
|
+
*/
|
|
85
|
+
modelWorkCoordinator?: ModelWorkCoordinator;
|
|
86
|
+
/**
|
|
87
|
+
* Optional override for `GET /v1/models`. When provided, that endpoint
|
|
88
|
+
* returns this list instead of `registry.list()`.
|
|
89
|
+
*/
|
|
90
|
+
listModels?: () => PublicModelEntry[];
|
|
91
|
+
/**
|
|
92
|
+
* Shared secret required on every route except `/health` and `/v1/health`.
|
|
93
|
+
*
|
|
94
|
+
* `undefined` (the default) disables the gate entirely and is byte-for-byte
|
|
95
|
+
* identical to the pre-auth behaviour: no header is inspected, no
|
|
96
|
+
* `WWW-Authenticate` is emitted, and CORS keeps its `true` default.
|
|
97
|
+
*
|
|
98
|
+
* Accepted as `x-api-key: <token>` (checked first — Anthropic clients send
|
|
99
|
+
* it) or `authorization: Bearer <token>` (scheme case-insensitive).
|
|
100
|
+
*/
|
|
101
|
+
authToken?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Builds the `/health` body. Supplied by `createServer` so the HTTP
|
|
104
|
+
* endpoint and `ServerInstance.health()` share one uptime origin. When
|
|
105
|
+
* omitted, `createHandler` builds its own reporter from `registry`,
|
|
106
|
+
* `idleSweeper` and `modelWorkCoordinator`.
|
|
107
|
+
*/
|
|
108
|
+
health?: () => ServerHealth;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createHandler(
|
|
112
|
+
registry: ModelRegistry,
|
|
113
|
+
options?: HandlerOptions,
|
|
114
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
115
|
+
const authToken = options?.authToken;
|
|
116
|
+
// CORS defaults follow the auth posture; an explicit value still wins.
|
|
117
|
+
const cors = options?.cors ?? authToken === undefined;
|
|
118
|
+
const store = options?.store ?? null;
|
|
119
|
+
const responseRetentionSec = options?.responseRetentionSec;
|
|
120
|
+
const idleSweeper = options?.idleSweeper ?? null;
|
|
121
|
+
const resolveModel = options?.resolveModel;
|
|
122
|
+
const modelWorkCoordinator =
|
|
123
|
+
options?.modelWorkCoordinator ?? (resolveModel ? new ModelWorkCoordinator(registry.queueDepthLimit) : undefined);
|
|
124
|
+
const listModels = options?.listModels;
|
|
125
|
+
const health =
|
|
126
|
+
options?.health ?? createHealthReporter({ registry, idleSweeper, modelWorkCoordinator: modelWorkCoordinator });
|
|
127
|
+
|
|
128
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
129
|
+
// The guard spans the WHOLE listener, not just `routeRequest`. `http.createServer`
|
|
130
|
+
// discards the returned promise, so anything that escapes here is an unhandled
|
|
131
|
+
// rejection — and Node's default `--unhandled-rejections=throw` turns that into
|
|
132
|
+
// process death. A crash-by-request is the one failure a request handler must
|
|
133
|
+
// not have, so nothing before the routing call gets to be outside the try either.
|
|
134
|
+
try {
|
|
135
|
+
if (cors) {
|
|
136
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
137
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
138
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key, anthropic-version');
|
|
139
|
+
|
|
140
|
+
if (req.method === 'OPTIONS') {
|
|
141
|
+
res.writeHead(204);
|
|
142
|
+
res.end();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Single auth choke point. `routeRequest` is called from exactly one
|
|
148
|
+
// place (below), so no route can be added that bypasses this.
|
|
149
|
+
//
|
|
150
|
+
// `authToken === undefined` short-circuits before any header is touched:
|
|
151
|
+
// an unprotected server behaves exactly as it did before auth existed.
|
|
152
|
+
let authenticated = true;
|
|
153
|
+
if (authToken !== undefined) {
|
|
154
|
+
authenticated = isAuthorized(req, authToken);
|
|
155
|
+
if (!authenticated) {
|
|
156
|
+
// Host-independent: see `requestPathname`. Building the base from the
|
|
157
|
+
// `Host` header threw on `Host: [`, from a branch only an
|
|
158
|
+
// UNAUTHENTICATED request reaches — so enabling auth was what made the
|
|
159
|
+
// server killable by anyone who could open the socket.
|
|
160
|
+
const path = requestPathname(req);
|
|
161
|
+
// `/health` degrades to a liveness-only body instead of 401 — but
|
|
162
|
+
// ONLY when no credential was offered. A caller who presented a
|
|
163
|
+
// WRONG token gets the 401 it needs to notice the typo.
|
|
164
|
+
if (!UNAUTHENTICATED_PATHS.has(path) || hasCredential(req)) {
|
|
165
|
+
sendUnauthorized(res);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Returning the promise lets tests await the full lifecycle including
|
|
172
|
+
// post-`res.end()` bookkeeping (e.g. `SessionRegistry.adopt`). `http.createServer`
|
|
173
|
+
// ignores the return value, so this is transparent to production callers.
|
|
174
|
+
await routeRequest(
|
|
175
|
+
req,
|
|
176
|
+
res,
|
|
177
|
+
registry,
|
|
178
|
+
store,
|
|
179
|
+
responseRetentionSec,
|
|
180
|
+
idleSweeper,
|
|
181
|
+
resolveModel,
|
|
182
|
+
listModels,
|
|
183
|
+
modelWorkCoordinator,
|
|
184
|
+
{ health, authenticated },
|
|
185
|
+
);
|
|
186
|
+
} catch (err: unknown) {
|
|
187
|
+
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
188
|
+
if (!res.headersSent) {
|
|
189
|
+
sendInternalError(res, message);
|
|
190
|
+
} else {
|
|
191
|
+
res.end();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server readiness reporting for supervisors (e.g. an Electron app running
|
|
3
|
+
* the inference server as a child process).
|
|
4
|
+
*
|
|
5
|
+
* The old `/health` returned a constant `{ status: 'ok' }`, which cannot
|
|
6
|
+
* distinguish four states a supervisor genuinely needs to tell apart:
|
|
7
|
+
*
|
|
8
|
+
* - up, nothing loaded → keep waiting, this is normal
|
|
9
|
+
* - up, model resident → route traffic
|
|
10
|
+
* - wedged mid-load → keep waiting, do NOT restart
|
|
11
|
+
* - wedged behind a full queue → shed load / warn the user
|
|
12
|
+
*
|
|
13
|
+
* Every input already exists in-process; this module only exposes it. The
|
|
14
|
+
* status ladder itself is a PURE function ({@link deriveHealthStatus}) over a
|
|
15
|
+
* plain record so it can be unit-tested without a server, a registry, or the
|
|
16
|
+
* native addon.
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: nothing here may call into `@mlx-node/core`. `/health` is
|
|
19
|
+
* deliberately excluded from the idle sweeper's `beginRequest`/`endRequest`
|
|
20
|
+
* bracket (see `HandlerOptions.idleSweeper`), so a native call from this path
|
|
21
|
+
* would run outside the in-flight accounting the drain timer relies on.
|
|
22
|
+
* Every field below is read from plain JavaScript state.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { IdleSweeper } from './idle-sweeper.js';
|
|
26
|
+
import type { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
27
|
+
import type { ModelRegistry } from './registry.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Coarse readiness classification. Ordered by precedence in
|
|
31
|
+
* {@link deriveHealthStatus} — a higher rung wins even when a lower rung's
|
|
32
|
+
* condition also holds.
|
|
33
|
+
*/
|
|
34
|
+
export type ServerHealthStatus = 'ok' | 'loading' | 'degraded' | 'error';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Outcome of the most recent load bracket, recorded by
|
|
38
|
+
* {@link ModelWorkCoordinator} in the `finally` of `withModelLoad` /
|
|
39
|
+
* `withModelLoadInstrumented`.
|
|
40
|
+
*
|
|
41
|
+
* Before this existed, a `resolveModel` failure became an HTTP 500 and was
|
|
42
|
+
* dropped — a supervisor polling after the fact had no way to learn WHY the
|
|
43
|
+
* server had no resident model.
|
|
44
|
+
*
|
|
45
|
+
* Resident HTTP requests bypass the load writer and do not overwrite this
|
|
46
|
+
* record with a no-op. The `'error'` rung is nevertheless gated on "no
|
|
47
|
+
* resident models": a server that is answering requests is not in an error
|
|
48
|
+
* state regardless of what the last load bracket did.
|
|
49
|
+
*/
|
|
50
|
+
export interface ModelLoadRecord {
|
|
51
|
+
/** Caller-supplied label, normally the model name. `null` when unlabelled. */
|
|
52
|
+
label: string | null;
|
|
53
|
+
/** `Date.now()` at writer-lock acquisition (when the load actually began). */
|
|
54
|
+
startedAt: number;
|
|
55
|
+
/** `Date.now()` when the bracket settled, success or failure. */
|
|
56
|
+
finishedAt: number;
|
|
57
|
+
ok: boolean;
|
|
58
|
+
/** Message of the thrown error, or `null` on success. */
|
|
59
|
+
error: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Inputs to the pure status ladder. Deliberately primitive so tests can fixture them. */
|
|
63
|
+
export interface HealthStatusInputs {
|
|
64
|
+
/** True while a load holds the coordinator's exclusive writer slot. */
|
|
65
|
+
writerActive: boolean;
|
|
66
|
+
/** Loads parked waiting for the writer slot. */
|
|
67
|
+
waitingWriters: number;
|
|
68
|
+
/** Inference requests currently bracketed by the idle sweeper. */
|
|
69
|
+
inFlight: number;
|
|
70
|
+
/** Distinct model names currently registered. */
|
|
71
|
+
residentModelCount: number;
|
|
72
|
+
/** True when at least one per-model queue is holding its configured max waiters. */
|
|
73
|
+
queueSaturated: boolean;
|
|
74
|
+
/** Most recent load bracket, or `null` if none has settled. */
|
|
75
|
+
lastLoad: ModelLoadRecord | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full readiness body. `{ status: 'ok' }` remains a strict subset of this. */
|
|
79
|
+
export interface ServerHealth {
|
|
80
|
+
status: ServerHealthStatus;
|
|
81
|
+
/** Milliseconds since the reporter was created (≈ server start). */
|
|
82
|
+
uptimeMs: number;
|
|
83
|
+
pid: number;
|
|
84
|
+
models: {
|
|
85
|
+
/** Registered names, including aliases. */
|
|
86
|
+
resident: string[];
|
|
87
|
+
count: number;
|
|
88
|
+
};
|
|
89
|
+
work: {
|
|
90
|
+
inFlight: number;
|
|
91
|
+
/** True while the idle sweeper has a `clearCache()` drain armed. */
|
|
92
|
+
drainPending: boolean;
|
|
93
|
+
writerActive: boolean;
|
|
94
|
+
waitingWriters: number;
|
|
95
|
+
};
|
|
96
|
+
queue: {
|
|
97
|
+
/** Deepest per-model waiter count across every session registry. */
|
|
98
|
+
depth: number;
|
|
99
|
+
/** Configured per-model waiter cap, or `null` when unbounded. */
|
|
100
|
+
limit: number | null;
|
|
101
|
+
saturated: boolean;
|
|
102
|
+
};
|
|
103
|
+
lastLoad: ModelLoadRecord | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The subset served to an UNAUTHENTICATED `/health` poll on a
|
|
108
|
+
* token-protected server. A supervisor must be able to poll before it holds a
|
|
109
|
+
* token, but `models.resident` is user data — model names routinely leak
|
|
110
|
+
* project names and local paths.
|
|
111
|
+
*/
|
|
112
|
+
export interface ServerHealthMinimal {
|
|
113
|
+
status: ServerHealthStatus;
|
|
114
|
+
uptimeMs: number;
|
|
115
|
+
pid: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pure status ladder. No I/O, no clock, no native calls — just the four
|
|
120
|
+
* documented rungs, in precedence order:
|
|
121
|
+
*
|
|
122
|
+
* 1. `writerActive` → 'loading'
|
|
123
|
+
* 2. last load failed AND nothing resident → 'error'
|
|
124
|
+
* 3. queue saturated, OR a load is parked
|
|
125
|
+
* behind live inference → 'degraded'
|
|
126
|
+
* 4. otherwise → 'ok'
|
|
127
|
+
*
|
|
128
|
+
* `'loading'` outranks `'error'` on purpose: a retry that already holds the
|
|
129
|
+
* writer slot means the supervisor should wait, not restart the process.
|
|
130
|
+
*/
|
|
131
|
+
export function deriveHealthStatus(input: HealthStatusInputs): ServerHealthStatus {
|
|
132
|
+
if (input.writerActive) return 'loading';
|
|
133
|
+
if (input.lastLoad?.ok === false && input.residentModelCount === 0) return 'error';
|
|
134
|
+
if (input.queueSaturated) return 'degraded';
|
|
135
|
+
// A writer parked while readers are still running means the swap cannot
|
|
136
|
+
// proceed until they drain, and every request for the incoming model
|
|
137
|
+
// stalls behind it. Either condition alone is an ordinary transient.
|
|
138
|
+
if (input.waitingWriters > 0 && input.inFlight > 0) return 'degraded';
|
|
139
|
+
return 'ok';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Project the full body down to the three fields safe to serve without a token. */
|
|
143
|
+
export function toMinimalHealth(health: ServerHealth): ServerHealthMinimal {
|
|
144
|
+
return { status: health.status, uptimeMs: health.uptimeMs, pid: health.pid };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface HealthReporterDeps {
|
|
148
|
+
registry: ModelRegistry;
|
|
149
|
+
/** Optional: supplies `inFlight` / `drainPending`. Absent ⇒ both read as idle. */
|
|
150
|
+
idleSweeper?: IdleSweeper | null;
|
|
151
|
+
/** Optional: supplies writer state + `lastLoad`. Absent ⇒ no load has ever run. */
|
|
152
|
+
modelWorkCoordinator?: ModelWorkCoordinator | null;
|
|
153
|
+
/** Defaults to `Date.now()` at construction. */
|
|
154
|
+
startedAt?: number;
|
|
155
|
+
/** Injectable clock for tests. */
|
|
156
|
+
now?: () => number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a zero-argument reporter closing over the live server objects. Each
|
|
161
|
+
* call re-reads current state; nothing is cached, so a supervisor polling on
|
|
162
|
+
* an interval always sees the present moment.
|
|
163
|
+
*/
|
|
164
|
+
export function createHealthReporter(deps: HealthReporterDeps): () => ServerHealth {
|
|
165
|
+
const now = deps.now ?? ((): number => Date.now());
|
|
166
|
+
const startedAt = deps.startedAt ?? now();
|
|
167
|
+
|
|
168
|
+
return (): ServerHealth => {
|
|
169
|
+
const resident = deps.registry.list().map((entry) => entry.id);
|
|
170
|
+
|
|
171
|
+
// Queue saturation is inherently per-model: one wedged model should
|
|
172
|
+
// surface even while others are idle. We report the WORST case.
|
|
173
|
+
let depth = 0;
|
|
174
|
+
let limit: number | null = null;
|
|
175
|
+
let saturated = false;
|
|
176
|
+
for (const sessionRegistry of deps.registry.listSessionRegistries()) {
|
|
177
|
+
const registryDepth = sessionRegistry.queueDepth;
|
|
178
|
+
if (registryDepth > depth) depth = registryDepth;
|
|
179
|
+
const registryLimit = sessionRegistry.queueDepthLimit;
|
|
180
|
+
if (registryLimit !== undefined) {
|
|
181
|
+
if (limit === null || registryLimit < limit) limit = registryLimit;
|
|
182
|
+
if (registryDepth >= registryLimit) saturated = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const writerActive = deps.modelWorkCoordinator?.writerActive ?? false;
|
|
187
|
+
const waitingWriters = deps.modelWorkCoordinator?.waitingWriters ?? 0;
|
|
188
|
+
const lastLoad = deps.modelWorkCoordinator?.lastLoad ?? null;
|
|
189
|
+
const inFlight = deps.idleSweeper?.inFlight ?? 0;
|
|
190
|
+
const drainPending = deps.idleSweeper?.isPending ?? false;
|
|
191
|
+
|
|
192
|
+
const status = deriveHealthStatus({
|
|
193
|
+
writerActive,
|
|
194
|
+
waitingWriters,
|
|
195
|
+
inFlight,
|
|
196
|
+
residentModelCount: resident.length,
|
|
197
|
+
queueSaturated: saturated,
|
|
198
|
+
lastLoad,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
status,
|
|
203
|
+
// Clamped: a backwards clock step (NTP, fake timers in a sibling test)
|
|
204
|
+
// must not surface a negative uptime a supervisor might read as a wrap.
|
|
205
|
+
uptimeMs: Math.max(0, now() - startedAt),
|
|
206
|
+
pid: process.pid,
|
|
207
|
+
models: { resident, count: resident.length },
|
|
208
|
+
work: { inFlight, drainPending, writerActive, waitingWriters },
|
|
209
|
+
queue: { depth, limit, saturated },
|
|
210
|
+
lastLoad,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Discover locally-downloaded generative models under a given directory. */
|
|
2
|
+
|
|
3
|
+
import type { LaunchPreset, ModelType } from '@mlx-node/lm/family-data';
|
|
4
|
+
import { discoverLocalChatModels } from '@mlx-node/lm/model-discovery';
|
|
5
|
+
|
|
6
|
+
/** A locally-downloaded model paired with its sampling preset. */
|
|
7
|
+
export interface DiscoveredModel {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
modelType: ModelType;
|
|
11
|
+
preset: LaunchPreset;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Use the same checkpoint IDs and paths as the agent and setup UI, including
|
|
16
|
+
* supported GGUF files and their quant variants. No weights are loaded here.
|
|
17
|
+
*/
|
|
18
|
+
export async function discoverModels(dir: string): Promise<DiscoveredModel[]> {
|
|
19
|
+
return (await discoverLocalChatModels(dir)).map(({ name, path, modelType, preset }) => ({
|
|
20
|
+
name,
|
|
21
|
+
path,
|
|
22
|
+
modelType,
|
|
23
|
+
preset,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine tuning that is LAUNCHER POLICY, not an engine default.
|
|
3
|
+
*
|
|
4
|
+
* The native engine reads these knobs from the process environment through a
|
|
5
|
+
* `OnceLock`: the FIRST read latches the value for the life of the process, so
|
|
6
|
+
* every variable here must be in place before any model is loaded and must
|
|
7
|
+
* never be mutated afterwards. That one-shot latch is why the policy is a
|
|
8
|
+
* plain data object rather than something a caller can toggle at runtime.
|
|
9
|
+
*
|
|
10
|
+
* Two application shapes, deliberately different:
|
|
11
|
+
*
|
|
12
|
+
* - In-process host (`mlx serve`, `mlx launch claude`): call
|
|
13
|
+
* {@link applyEnginePolicy} to write `process.env` BEFORE the first load.
|
|
14
|
+
* A value the user already set in their shell always wins — this is a
|
|
15
|
+
* default, not an override.
|
|
16
|
+
* - Out-of-process host (Electron `utilityProcess.fork`): pass
|
|
17
|
+
* {@link engineEnvFor} into `fork({ env })` and never touch
|
|
18
|
+
* `process.env` in the child. The returned map is unconditional: it
|
|
19
|
+
* describes the child's starting environment, and the parent is expected
|
|
20
|
+
* to spread the user's own env under it if it wants shell values to win.
|
|
21
|
+
*
|
|
22
|
+
* Keeping the two apart matters. `MLX_PAGED_PREFILL_CHUNK_SIZE` reads as
|
|
23
|
+
* `0` ("no chunking") in Rust when unset; 2048 is the value
|
|
24
|
+
* `mlx launch claude` has historically applied to bound the cold-prefill
|
|
25
|
+
* memory peak, and baking it into the engine would silently change every
|
|
26
|
+
* other embedder's behaviour.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Env var names this module owns. Exported so tests can assert the exact set. */
|
|
30
|
+
export const ENGINE_POLICY_ENV_VARS = ['MLX_PAGED_PREFILL_CHUNK_SIZE'] as const;
|
|
31
|
+
|
|
32
|
+
export interface EnginePolicy {
|
|
33
|
+
/**
|
|
34
|
+
* `MLX_PAGED_PREFILL_CHUNK_SIZE` — tokens per paged-prefill chunk.
|
|
35
|
+
* `0` disables chunking (the Rust default when the var is unset).
|
|
36
|
+
*/
|
|
37
|
+
pagedPrefillChunkSize?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The policy `mlx launch claude` has applied since the flag existed, and the
|
|
42
|
+
* one `mlx serve` and the desktop sidecar inherit.
|
|
43
|
+
*
|
|
44
|
+
* 2048 matches the mlx-lm / mlx-vlm default and reduces per-chunk overhead
|
|
45
|
+
* versus the older 1024 for long Qwen dense contexts.
|
|
46
|
+
*/
|
|
47
|
+
export const LAUNCHER_ENGINE_POLICY: EnginePolicy = Object.freeze({ pagedPrefillChunkSize: 2048 });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render a policy as the environment map a forked child should start with.
|
|
51
|
+
*
|
|
52
|
+
* Unconditional by construction: there is no "already set" to respect in a
|
|
53
|
+
* child that does not exist yet.
|
|
54
|
+
*/
|
|
55
|
+
export function engineEnvFor(policy: EnginePolicy): Record<string, string> {
|
|
56
|
+
const env: Record<string, string> = {};
|
|
57
|
+
if (policy.pagedPrefillChunkSize !== undefined) {
|
|
58
|
+
env.MLX_PAGED_PREFILL_CHUNK_SIZE = String(policy.pagedPrefillChunkSize);
|
|
59
|
+
}
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply a policy to an in-process environment as a DEFAULT.
|
|
65
|
+
*
|
|
66
|
+
* Only writes vars that are currently unset — matching the historical
|
|
67
|
+
* `if (process.env.X == null)` guard, so an explicit `X=` (empty string) in
|
|
68
|
+
* the user's shell is treated as "the user has an opinion" and left alone.
|
|
69
|
+
*
|
|
70
|
+
* Returns the names actually written, so a caller can log or assert on them.
|
|
71
|
+
*/
|
|
72
|
+
export function applyEnginePolicy(policy: EnginePolicy, env: NodeJS.ProcessEnv = process.env): string[] {
|
|
73
|
+
const applied: string[] = [];
|
|
74
|
+
for (const [name, value] of Object.entries(engineEnvFor(policy))) {
|
|
75
|
+
if (env[name] == null) {
|
|
76
|
+
env[name] = value;
|
|
77
|
+
applied.push(name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return applied;
|
|
81
|
+
}
|