@mlx-node/server 0.0.12 → 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/server.ts ADDED
@@ -0,0 +1,579 @@
1
+ /** Full HTTP server lifecycle: wires up the handler and periodically sweeps expired `ResponseStore` rows and sessions. */
2
+
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { createServer as httpCreateServer } from 'node:http';
5
+ import type { Server, ServerResponse } from 'node:http';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ import { ResponseStore } from '@mlx-node/core';
10
+
11
+ import type { PublicModelEntry } from './handler.js';
12
+ import { createHandler } from './handler.js';
13
+ import { createHealthReporter, type ServerHealth } from './health.js';
14
+ import { createIdleSweeper, DEFAULT_IDLE_CLEAR_CACHE_MS, parseIdleClearCacheEnv } from './idle-sweeper.js';
15
+ import { runGuardedModelLoad, type LoadModelOptions } from './load-model.js';
16
+ import { ModelWorkCoordinator } from './model-work-coordinator.js';
17
+ import { ModelRegistry } from './registry.js';
18
+ import { activeSSEStreamCountForResponses } from './streaming.js';
19
+
20
+ /** Cleanup interval for expired responses (ms). */
21
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
22
+
23
+ /**
24
+ * Default grace period before {@link ServerInstance.close} destroys
25
+ * still-open connections.
26
+ *
27
+ * 5 s is deliberately a little longer than the ~5 s GPU watchdog window: a
28
+ * decode loop that is about to yield its next token should get the chance to
29
+ * unwind cleanly rather than being cut off a hair early.
30
+ */
31
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
32
+
33
+ /**
34
+ * Default retention for persisted response rows in SQLite, in seconds.
35
+ *
36
+ * Decoupled from the in-memory `SessionRegistry` TTL (30 min) so a client
37
+ * sending `previous_response_id` after the warm KV cache has been evicted
38
+ * can still cold-replay from disk via `reconstructMessagesFromChain` +
39
+ * `ChatSession.startFromHistory`. 7 days trades disk for continuity at the
40
+ * cost of a one-time prefill on recovery.
41
+ */
42
+ const DEFAULT_RESPONSE_RETENTION_SECONDS = 7 * 24 * 60 * 60; // 7 days
43
+
44
+ /**
45
+ * Default per-model queue-depth cap (waiters-only; the actively running
46
+ * dispatch does not count). Applied when neither
47
+ * {@link ServerConfig.maxQueueDepthPerModel} nor the
48
+ * `MLX_MAX_QUEUE_DEPTH_PER_MODEL` env var is set; the config sentinel
49
+ * `'unbounded'` opts out entirely.
50
+ *
51
+ * 16, not 8: an agent fan-out (e.g. Claude Code bursting parallel tool
52
+ * calls) can put well over 8 concurrent requests behind one model from a
53
+ * SINGLE client, so 8 risked 429s on a lone user. Over-cap requests get
54
+ * HTTP 429 with `Retry-After: 1`, which the OpenAI/Anthropic SDK retry
55
+ * loops honor — the cap sheds pile-up instead of failing well-behaved
56
+ * clients.
57
+ */
58
+ export const DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL = 16;
59
+
60
+ /**
61
+ * Parse a positive integer seconds value; returns undefined for unset/invalid so caller can apply its own default.
62
+ *
63
+ * Non-integer positive values (e.g. `"1.5"`) are rejected rather than
64
+ * silently truncated — a typo like `"1.5"` meant as `"15"` would otherwise
65
+ * be accepted as 1 second, expiring persisted response rows almost
66
+ * immediately and breaking `previous_response_id` continuity. We prefer
67
+ * falling through to the caller's default over crashing on startup so a
68
+ * config-template typo in a Dockerfile / CI manifest does not take the
69
+ * service down.
70
+ *
71
+ * Exported for unit tests.
72
+ */
73
+ export function parseEnvSeconds(name: string): number | undefined {
74
+ const raw = process.env[name];
75
+ if (raw == null || raw === '') return undefined;
76
+ const parsed = Number(raw);
77
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
78
+ if (!Number.isInteger(parsed)) return undefined;
79
+ return parsed;
80
+ }
81
+
82
+ /**
83
+ * Parse a positive integer count from env; shares the reject-unset-or-invalid
84
+ * semantics used by {@link parseEnvSeconds} (including the non-integer
85
+ * reject) so callers can fall back to their own default when the var is
86
+ * missing or malformed.
87
+ *
88
+ * Exported for unit tests.
89
+ */
90
+ export function parseEnvPositiveInt(name: string): number | undefined {
91
+ const raw = process.env[name];
92
+ if (raw == null || raw === '') return undefined;
93
+ const parsed = Number(raw);
94
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
95
+ if (!Number.isInteger(parsed)) return undefined;
96
+ return parsed;
97
+ }
98
+
99
+ /**
100
+ * Validate a caller-supplied positive-integer config knob.
101
+ *
102
+ * Mirrors the reject-invalid semantics of {@link parseEnvPositiveInt} /
103
+ * {@link parseEnvSeconds} but fails fast with a descriptive error when
104
+ * the caller explicitly passes a bogus value. Silent coercion would hide
105
+ * a config bug that can take the model offline (e.g. a
106
+ * `maxQueueDepthPerModel: 0` makes `queuedCount >= limit` true for every
107
+ * request, immediately returning HTTP 429; a `responseRetentionSec: 0`
108
+ * stamps `expires_at = now` on every row and the next cleanup sweep
109
+ * deletes it). `undefined` falls through so the env/default path still
110
+ * applies.
111
+ */
112
+ function normalizePositiveIntConfig(value: number | undefined, name: string): number | undefined {
113
+ if (value === undefined) return undefined;
114
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
115
+ throw new Error(`${name} must be a positive integer; received ${String(value)}`);
116
+ }
117
+ return value;
118
+ }
119
+
120
+ /**
121
+ * Resolve the effective auth token from an explicit value plus the env
122
+ * fallback.
123
+ *
124
+ * Exported because `createInferenceHost` has to answer "is this server going
125
+ * to be protected?" BEFORE it binds, in order to refuse a non-loopback bind
126
+ * that would serve anonymously. Two independent copies of this rule would
127
+ * drift, and the direction it would drift is a host that refuses to start
128
+ * while `MLX_SERVER_AUTH_TOKEN` is sitting right there in the environment.
129
+ *
130
+ * An empty env var means "not set". An accidental `MLX_SERVER_AUTH_TOKEN=` in
131
+ * a launcher script must not enable auth with an empty secret that every
132
+ * credential-less request would then fail against.
133
+ *
134
+ * An empty EXPLICIT token is a different case and is rejected outright, for the
135
+ * same reason {@link normalizePositiveIntConfig} rejects a bogus explicit knob:
136
+ * somebody asked for a token and supplied nothing, which is
137
+ * `--auth-token "$TOKEN"` with `TOKEN` unset. Returning `''` made the bind
138
+ * guard read "auth is configured" and allow `0.0.0.0`, while the comparator
139
+ * accepted an empty `x-api-key` because both strings were empty — a wildcard
140
+ * bind published under a credential anyone can guess. Quietly downgrading to
141
+ * "no auth" instead would be fail-open in the other direction: on loopback it
142
+ * hands back the unauthenticated, wildcard-CORS server the operator was
143
+ * explicitly trying not to start. Throwing is the only answer that is wrong in
144
+ * neither bind mode, and it happens before anything binds or loads.
145
+ */
146
+ export function resolveAuthToken(explicit: string | undefined): string | undefined {
147
+ if (explicit === '') {
148
+ throw new Error(
149
+ 'authToken was set to an empty string; pass a real secret or omit it entirely ' +
150
+ '(an unset shell variable in `--auth-token "$VAR"` is the usual cause).',
151
+ );
152
+ }
153
+ if (explicit !== undefined) return explicit;
154
+ const fromEnv = process.env.MLX_SERVER_AUTH_TOKEN;
155
+ return fromEnv != null && fromEnv !== '' ? fromEnv : undefined;
156
+ }
157
+
158
+ export interface ServerConfig {
159
+ /** Port to listen on (default: 8080). */
160
+ port?: number;
161
+ /** Hostname to bind to (default: '127.0.0.1'). */
162
+ host?: string;
163
+ /** Path to the SQLite response store (default: ~/.mlx-node/responses.db). */
164
+ storePath?: string;
165
+ /** Disable response storage entirely (default: false). */
166
+ disableStore?: boolean;
167
+ /**
168
+ * Enable CORS headers.
169
+ *
170
+ * Default: `true` when no `authToken` is in effect (historical behaviour),
171
+ * `false` once one is. An explicit value always wins. See
172
+ * {@link ServerConfig.authToken}.
173
+ */
174
+ cors?: boolean;
175
+ /**
176
+ * Shared secret required on every route except `/health` and `/v1/health`.
177
+ *
178
+ * Default: `process.env.MLX_SERVER_AUTH_TOKEN`, or `undefined` (no auth)
179
+ * when that is unset or empty. `undefined` is byte-for-byte identical to
180
+ * the pre-auth behaviour. An explicit `''` is rejected rather than treated as
181
+ * either — see {@link resolveAuthToken}.
182
+ *
183
+ * Accepted as `x-api-key: <token>` or `authorization: Bearer <token>`.
184
+ * Setting it also flips the `cors` default to `false`.
185
+ */
186
+ authToken?: string;
187
+ /**
188
+ * Retention for persisted response rows, in seconds. Stamped as `expires_at`
189
+ * on each committed response; controls how long `previous_response_id`
190
+ * cold-replay from SQLite remains possible after the warm session is evicted.
191
+ *
192
+ * Default: 7 days. Env override: `MLX_RESPONSE_RETENTION_SECONDS`. Ignored
193
+ * when `disableStore` is true.
194
+ */
195
+ responseRetentionSec?: number;
196
+ /**
197
+ * Maximum number of concurrent requests that may be WAITING for the
198
+ * per-model execution mutex (the one actively running does not count).
199
+ * When the cap is reached, further requests return HTTP 429 with a
200
+ * `Retry-After: 1` header so clients can back off instead of piling
201
+ * into an unbounded queue.
202
+ *
203
+ * Default: {@link DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL} (16). Env
204
+ * override: `MLX_MAX_QUEUE_DEPTH_PER_MODEL` (positive integer). Pass
205
+ * `'unbounded'` to opt out of the cap entirely (the pre-cap
206
+ * behaviour); the sentinel also bypasses the env override, because an
207
+ * explicit config value always wins over env.
208
+ */
209
+ maxQueueDepthPerModel?: number | 'unbounded';
210
+ /**
211
+ * Milliseconds of HTTP inactivity (no request arrivals or completions)
212
+ * before the server issues a single `clearCache()` to drain the MLX
213
+ * Metal allocator's free pool. Replaces the old per-request
214
+ * `ClearCacheOnDrop` guard in the Rust layer, which was unsafe on a
215
+ * multi-model server because the pool is process-wide.
216
+ *
217
+ * Default: 30_000 ms (30 seconds). `0` disables the sweeper. Env
218
+ * override: `MLX_IDLE_CLEAR_CACHE_MS` (non-negative integer; 0
219
+ * disables; unset falls through to default).
220
+ *
221
+ * The decode-loop drain every 256 tokens inside each generative
222
+ * model is untouched and covers in-flight memory churn.
223
+ */
224
+ idleClearCacheMs?: number;
225
+ /**
226
+ * Optional async callback invoked before the endpoint layer looks
227
+ * the model up in the registry. Intended for lazy-load schemes:
228
+ * the callback should register the model into `registry` if it can;
229
+ * on return, the endpoint does `registry.get(body.model)` and 404s
230
+ * if still unresolved. Callback errors bubble up as 500s.
231
+ */
232
+ resolveModel?: (name: string) => Promise<void>;
233
+ /**
234
+ * Optional override for `GET /v1/models` enumeration. When provided,
235
+ * the endpoint returns this list instead of `registry.list()`.
236
+ * Intended for dynamic discovery schemes (e.g. enumerate every model
237
+ * on disk while only the currently-resident one is registered).
238
+ */
239
+ listModels?: () => PublicModelEntry[];
240
+ }
241
+
242
+ /** Options for {@link ServerInstance.close}. */
243
+ export interface CloseOptions {
244
+ /**
245
+ * Grace period, in milliseconds, before still-open connections are
246
+ * destroyed. Default: {@link DEFAULT_CLOSE_TIMEOUT_MS} (5000).
247
+ *
248
+ * Only the FIRST `close()` call's value is honoured — later calls receive
249
+ * the memoized promise of the first, so their timeout is ignored.
250
+ */
251
+ timeoutMs?: number;
252
+ }
253
+
254
+ /** Outcome of {@link ServerInstance.close}. */
255
+ export interface CloseResult {
256
+ /** `true` when the grace period expired and connections were destroyed. */
257
+ forced: boolean;
258
+ /** SSE streams open at the moment of the forced destroy. `0` when not forced. */
259
+ streamsAborted: number;
260
+ /** Wall-clock duration of the shutdown. */
261
+ durationMs: number;
262
+ }
263
+
264
+ export interface ServerInstance {
265
+ server: Server;
266
+ /** Register models before or after starting. */
267
+ registry: ModelRegistry;
268
+ /** Null when disabled. */
269
+ store: ResponseStore | null;
270
+ /**
271
+ * Coordinates process-wide MLX work: model loads take the exclusive writer
272
+ * slot, inference takes shared reader slots. Exposed so callers can compose
273
+ * their own brackets (or read `writerActive` / `lastLoad` for diagnostics).
274
+ * Prefer {@link loadModel} for the common load case — it also handles the
275
+ * drain suspension, which is easy to get wrong.
276
+ */
277
+ readonly modelWork: ModelWorkCoordinator;
278
+ /**
279
+ * Current readiness snapshot — the same body an authenticated
280
+ * `GET /health` returns. Pure JavaScript state; no native calls.
281
+ */
282
+ health(): ServerHealth;
283
+ /**
284
+ * Bounded, idempotent shutdown.
285
+ *
286
+ * Stops accepting new connections, drops idle (keep-alive) ones
287
+ * immediately, then waits up to `timeoutMs` for the rest to finish. On
288
+ * expiry every remaining connection is destroyed, which fires the same
289
+ * `res.on('close')` path a client disconnect fires — so in-flight SSE
290
+ * generations are cancelled through `@mlx-node/lm` down to the native
291
+ * `ChatStreamHandle`.
292
+ *
293
+ * Idempotent: the promise is memoized, so repeated calls return the same
294
+ * promise and the same result. In particular a second call does NOT
295
+ * reject with `ERR_SERVER_NOT_RUNNING`.
296
+ *
297
+ * RESIDUAL: `ResponseStore` has no `close()` (it is a Rust-side handle),
298
+ * so the SQLite connection is released by process exit, not here. A
299
+ * long-lived process that creates and closes many servers will hold one
300
+ * store handle per server.
301
+ */
302
+ close(opts?: CloseOptions): Promise<CloseResult>;
303
+ /**
304
+ * Load a model out-of-band and register it, with idle drains suspended and
305
+ * inference excluded for the entire operation — including the wait for the
306
+ * coordinator's writer lock.
307
+ *
308
+ * This is the safe way to swap the resident model on a server that is
309
+ * already serving. Hand-rolling the two brackets in the wrong order races
310
+ * the process-wide Metal allocator; see `load-model.ts` for the full
311
+ * rationale.
312
+ *
313
+ * Rejects with the underlying error if `load()` throws; both brackets
314
+ * unwind cleanly, and `health().lastLoad` records the failure under
315
+ * `opts.name`.
316
+ */
317
+ loadModel(opts: LoadModelOptions): Promise<void>;
318
+ /**
319
+ * Run `fn` with the idle-drain timer suspended for the duration of
320
+ * an unbracketed, allocator-heavy operation — most commonly a hot
321
+ * `Model::load()` invoked AFTER the server has already served at
322
+ * least one request. In that scenario the post-request drain timer
323
+ * armed by `endRequest()` (at t+idleClearCacheMs) can otherwise
324
+ * fire MID-LOAD, racing the Metal allocator while weight
325
+ * materialization is still in progress.
326
+ *
327
+ * Handles try/finally bracketing so a thrown load never leaks the
328
+ * internal suspend counter. Accepts both sync and async functions;
329
+ * returns `fn`'s own return value (or resolved promise). When the
330
+ * bracket exits (normal or thrown) AND `inFlight === 0` with no
331
+ * other suspend active, a fresh drain timer is armed.
332
+ *
333
+ * Safe to nest — each call allocates its own token-scoped release
334
+ * so overlapping brackets unwind independently.
335
+ *
336
+ * The common `serve.ts` pattern (load all models BEFORE
337
+ * `createServer()`) does not need this API — there is no armed
338
+ * timer before the first request, so there is no race.
339
+ *
340
+ * Pass-through when the sweeper is disabled (`idleClearCacheMs: 0`
341
+ * or missing `__internal__.clearCache`): `fn` is invoked directly.
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * await instance.withSuspendedDrains(async () => {
346
+ * const model = await Qwen35Model.load(modelPath);
347
+ * instance.registry.register('new-model', model);
348
+ * });
349
+ * ```
350
+ */
351
+ withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
352
+ withSuspendedDrains<T>(fn: () => T): T;
353
+ /**
354
+ * Low-level: suspend drains and return an idempotent, token-scoped
355
+ * disposer. Prefer {@link withSuspendedDrains} unless you need
356
+ * manual control over when the suspend is released. Safe to nest;
357
+ * calling the disposer more than once is a no-op.
358
+ *
359
+ * No-op when the sweeper is disabled (`idleClearCacheMs: 0` or
360
+ * missing `__internal__.clearCache`): returns a no-op disposer.
361
+ */
362
+ suspendDrains(): () => void;
363
+ }
364
+
365
+ /**
366
+ * Start an MLX-Node HTTP server exposing `POST /v1/responses`,
367
+ * `POST /v1/messages`, and `GET /v1/models`.
368
+ *
369
+ * @example
370
+ * ```typescript
371
+ * const { registry, close } = await createServer({ port: 8080 });
372
+ * registry.register('qwen3.5-3b', await Qwen35Model.load('./models/qwen3.5-3b'));
373
+ * ```
374
+ */
375
+ export async function createServer(config?: ServerConfig): Promise<ServerInstance> {
376
+ const port = config?.port ?? 8080;
377
+ const host = config?.host ?? '127.0.0.1';
378
+ const authToken = resolveAuthToken(config?.authToken);
379
+ // Forwarded unresolved so `createHandler` applies the auth-aware default
380
+ // (`true` without a token, `false` with one) in exactly one place.
381
+ const cors = config?.cors;
382
+ const disableStore = config?.disableStore ?? false;
383
+ // Validate caller-supplied numeric knobs BEFORE consulting env fallbacks
384
+ // so a bogus explicit value surfaces as a descriptive error instead of
385
+ // silently falling through to env / default. See
386
+ // `normalizePositiveIntConfig` for the failure modes we're guarding.
387
+ const configRetentionSec = normalizePositiveIntConfig(config?.responseRetentionSec, 'responseRetentionSec');
388
+ const responseRetentionSec =
389
+ configRetentionSec ?? parseEnvSeconds('MLX_RESPONSE_RETENTION_SECONDS') ?? DEFAULT_RESPONSE_RETENTION_SECONDS;
390
+ // Queue-depth cap; resolved exactly once at server construction so the
391
+ // registry (and its per-model `SessionRegistry` instances allocated on
392
+ // `register()`) all share a single effective value. Precedence:
393
+ // explicit config (the `'unbounded'` sentinel disables the cap
394
+ // outright, short-circuiting env) wins over env wins over the default
395
+ // of {@link DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL}. Bogus numeric config
396
+ // still fails fast in `normalizePositiveIntConfig` before any env /
397
+ // default fallback applies.
398
+ const maxQueueDepthConfig = config?.maxQueueDepthPerModel;
399
+ const maxQueueDepthPerModel =
400
+ maxQueueDepthConfig === 'unbounded'
401
+ ? undefined
402
+ : (normalizePositiveIntConfig(maxQueueDepthConfig, 'maxQueueDepthPerModel') ??
403
+ parseEnvPositiveInt('MLX_MAX_QUEUE_DEPTH_PER_MODEL') ??
404
+ DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL);
405
+
406
+ // Idle sweeper wiring. `0` is a legal explicit "off" value so we
407
+ // cannot reuse `normalizePositiveIntConfig` (which rejects 0).
408
+ // Precedence: explicit config value wins over env wins over default.
409
+ const idleClearCacheMs = resolveIdleClearCacheMs(config?.idleClearCacheMs);
410
+ const idleSweeper = createIdleSweeper(idleClearCacheMs);
411
+ // The sweeper is driven exclusively by `endRequest()` calls in the
412
+ // inference endpoints (`/v1/responses`, `/v1/messages`). There is no
413
+ // cold-start drain: a process that loads models but never receives a
414
+ // request will not fire `clearCache()`. See the `idle-sweeper.ts`
415
+ // module doc (section "Drain is post-request only") for rationale.
416
+ const registry = new ModelRegistry({ maxQueueDepth: maxQueueDepthPerModel });
417
+ const modelWorkCoordinator = new ModelWorkCoordinator(maxQueueDepthPerModel);
418
+
419
+ let store: ResponseStore | null = null;
420
+ if (!disableStore) {
421
+ const storePath = config?.storePath ?? join(homedir(), '.mlx-node', 'responses.db');
422
+ const storeDir = join(storePath, '..');
423
+ await mkdir(storeDir, { recursive: true });
424
+ store = await ResponseStore.open(storePath);
425
+ }
426
+
427
+ // Always schedule the sweep — sessions need TTL sweeps even without a store.
428
+ const cleanupTimer: ReturnType<typeof setInterval> = setInterval(() => {
429
+ if (store) {
430
+ store.cleanupExpired().catch(() => {});
431
+ }
432
+ for (const sessReg of registry.listSessionRegistries()) {
433
+ sessReg.sweep();
434
+ }
435
+ }, CLEANUP_INTERVAL_MS);
436
+ cleanupTimer.unref();
437
+
438
+ // One reporter shared by the HTTP endpoint and `ServerInstance.health()`
439
+ // so both report the same uptime origin.
440
+ const health = createHealthReporter({ registry, idleSweeper, modelWorkCoordinator });
441
+
442
+ const handler = createHandler(registry, {
443
+ cors,
444
+ store,
445
+ responseRetentionSec,
446
+ idleSweeper,
447
+ resolveModel: config?.resolveModel,
448
+ modelWorkCoordinator,
449
+ listModels: config?.listModels,
450
+ authToken,
451
+ health,
452
+ });
453
+ /**
454
+ * Responses owned by THIS HTTP server. The SSE registry is deliberately
455
+ * process-wide because `beginSSE` is also used by standalone handlers;
456
+ * shutdown accounting intersects it with this weak ownership set so one
457
+ * server cannot claim streams that another server leaves live. A WeakSet
458
+ * needs no second response cleanup lifecycle.
459
+ */
460
+ const ownedResponses = new WeakSet<ServerResponse>();
461
+ const server = httpCreateServer((req, res) => {
462
+ // Synchronous, before `handler` can reach either endpoint's `beginSSE`.
463
+ ownedResponses.add(res);
464
+ void handler(req, res);
465
+ });
466
+
467
+ await new Promise<void>((resolve, reject) => {
468
+ const onError = (err: Error) => {
469
+ server.removeListener('error', onError);
470
+ reject(err);
471
+ };
472
+ server.on('error', onError);
473
+ server.listen(port, host, () => {
474
+ server.removeListener('error', onError);
475
+ resolve();
476
+ });
477
+ });
478
+
479
+ // Memoized so a second `close()` returns the first call's promise instead
480
+ // of re-entering `server.close()` (which invokes its callback with
481
+ // ERR_SERVER_NOT_RUNNING once the server is already down).
482
+ let closePromise: Promise<CloseResult> | null = null;
483
+
484
+ const close = (opts?: CloseOptions): Promise<CloseResult> => {
485
+ if (closePromise !== null) return closePromise;
486
+ const startedAt = Date.now();
487
+ const requested = opts?.timeoutMs;
488
+ const timeoutMs =
489
+ typeof requested === 'number' && Number.isFinite(requested) && requested >= 0
490
+ ? requested
491
+ : DEFAULT_CLOSE_TIMEOUT_MS;
492
+
493
+ closePromise = (async (): Promise<CloseResult> => {
494
+ clearInterval(cleanupTimer);
495
+ idleSweeper.close();
496
+
497
+ let forced = false;
498
+ let streamsAborted = 0;
499
+
500
+ // Arm the wait BEFORE dropping idle sockets so nothing can complete in
501
+ // the gap and settle `server.close()` before we are listening.
502
+ const serverClosed = new Promise<void>((resolve, reject) => {
503
+ server.close((err) => {
504
+ // Tolerated defensively: memoization should make this unreachable,
505
+ // but a caller who also closed `instance.server` directly would
506
+ // otherwise turn a successful shutdown into a rejection.
507
+ if (err && (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') reject(err);
508
+ else resolve();
509
+ });
510
+ });
511
+
512
+ // Keep-alive sockets parked in a client pool hold no request; without
513
+ // this the shutdown would sit on them until the client's own timeout.
514
+ server.closeIdleConnections();
515
+
516
+ const forceTimer = setTimeout(() => {
517
+ forced = true;
518
+ // Snapshot BEFORE destroying: `closeAllConnections()` fires each
519
+ // response's `'close'` event, which unregisters it from the global SSE
520
+ // registry used by the intersection.
521
+ streamsAborted = activeSSEStreamCountForResponses(ownedResponses);
522
+ // Destroying the socket fires exactly the `res.on('close')` path a
523
+ // client disconnect fires, so the endpoint's AbortController cancels
524
+ // the native stream handle rather than leaking a running decode.
525
+ server.closeAllConnections();
526
+ }, timeoutMs);
527
+
528
+ try {
529
+ await serverClosed;
530
+ } finally {
531
+ clearTimeout(forceTimer);
532
+ }
533
+
534
+ return { forced, streamsAborted, durationMs: Date.now() - startedAt };
535
+ })();
536
+
537
+ return closePromise;
538
+ };
539
+
540
+ return {
541
+ server,
542
+ registry,
543
+ store,
544
+ modelWork: modelWorkCoordinator,
545
+ health,
546
+ close,
547
+ loadModel(opts: LoadModelOptions): Promise<void> {
548
+ return runGuardedModelLoad({ idleSweeper, modelWorkCoordinator, registry }, opts);
549
+ },
550
+ withSuspendedDrains<T>(fn: () => T | Promise<T>): T | Promise<T> {
551
+ return idleSweeper.withSuspendedDrains(fn as () => Promise<T>);
552
+ },
553
+ suspendDrains(): () => void {
554
+ return idleSweeper.suspendDrains();
555
+ },
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Resolve the effective idle-drain delay from (constructor value, env,
561
+ * default). Unlike the other knobs, `0` is a legal explicit opt-out —
562
+ * we must NOT fall through to env/default when the caller explicitly
563
+ * passes `0`. Returns a non-negative integer milliseconds value.
564
+ */
565
+ function resolveIdleClearCacheMs(configValue: number | undefined): number {
566
+ if (configValue !== undefined) {
567
+ if (
568
+ typeof configValue !== 'number' ||
569
+ !Number.isFinite(configValue) ||
570
+ !Number.isInteger(configValue) ||
571
+ configValue < 0
572
+ ) {
573
+ throw new Error(`idleClearCacheMs must be a non-negative integer; received ${String(configValue)}`);
574
+ }
575
+ return configValue;
576
+ }
577
+ const envValue = parseIdleClearCacheEnv();
578
+ return envValue ?? DEFAULT_IDLE_CLEAR_CACHE_MS;
579
+ }