@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.
Files changed (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. 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
+ }