@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
@@ -0,0 +1,416 @@
1
+ import type { ModelLoadRecord } from './health.js';
2
+ import type { PreDispatchAdmission, SessionRegistry } from './session-registry.js';
3
+
4
+ /**
5
+ * Render a thrown value for {@link ModelLoadRecord.error}.
6
+ *
7
+ * Deliberately avoids `String(unknown)`: a rejection carrying a plain object
8
+ * would render as the useless `[object Object]` in the one field a supervisor
9
+ * reads to find out why the model would not load.
10
+ */
11
+ function describeLoadFailure(error: unknown): string {
12
+ if (error instanceof Error) return error.message;
13
+ if (typeof error === 'string') return error;
14
+ if (error == null) return 'unknown error';
15
+ try {
16
+ return JSON.stringify(error) ?? 'unknown error';
17
+ } catch {
18
+ // Circular structure, or a `toJSON` that throws.
19
+ return 'unknown error';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Result of a `withModelLoad` call that surfaces who actually drove the load
25
+ * vs. who merely parked behind one that was already in flight. Callers use
26
+ * this to split observability between the request that triggered a cold
27
+ * weight-materialize from one that arrived a millisecond later and merely
28
+ * inherited the wait — without the split a 60-second cold-load shows up
29
+ * on every concurrent request as if each one paid for a separate load.
30
+ *
31
+ * `owner` reflects the SYNCHRONOUS state observed at lock acquisition:
32
+ * `true` if the writer lock was free when this caller arrived and the
33
+ * caller itself executed the supplied `fn`; `false` if there was already
34
+ * a writer active (or queued ahead of this caller) when it arrived.
35
+ *
36
+ * `waitMs` and `ownMs` partition the wall-clock interval between when
37
+ * the caller arrived at the coordinator and when its `fn` resolved:
38
+ * - `waitMs` is time spent blocked inside `acquireWrite()` (zero for a
39
+ * no-contention owner; ≈ peer's load duration for a follower).
40
+ * - `ownMs` is time spent inside `fn` once the writer lock was held
41
+ * (≈ load duration for an owner driving a cold load; near-zero for a
42
+ * follower whose `fn` is a no-op cache lookup).
43
+ * Both are measured from `Date.now()` and clamped at zero to absorb
44
+ * monotonic-skew. Their sum equals the total elapsed time in the call,
45
+ * so handlers can plumb them into separate observability fields
46
+ * (`server_load_wait_ms` vs. `server_model_resolve_ms`) without
47
+ * double-counting.
48
+ */
49
+ export interface ModelLoadOutcome<T> {
50
+ result: T;
51
+ owner: boolean;
52
+ waitMs: number;
53
+ ownMs: number;
54
+ }
55
+
56
+ /** A bounded request admission transferred into the resident FIFO budget. */
57
+ export interface ModelLoadAdmission {
58
+ /**
59
+ * Atomically move this request from the cold-load budget into the
60
+ * resident model's ordinary pre-dispatch budget. Idempotent for the same
61
+ * registry; transferring after a hot-swap releases the old reservation
62
+ * before charging the new binding.
63
+ */
64
+ transferToResident(registry: SessionRegistry): PreDispatchAdmission;
65
+ /** Idempotently release this request's current cold or resident unit. */
66
+ release(): void;
67
+ }
68
+
69
+ interface ModelLoadAdmissionState {
70
+ released: boolean;
71
+ coldCounted: boolean;
72
+ residentRegistry?: SessionRegistry;
73
+ residentAdmission?: PreDispatchAdmission;
74
+ transferError?: unknown;
75
+ }
76
+
77
+ /** Raised synchronously when unresolved model-load traffic is over capacity. */
78
+ export class ModelLoadQueueFullError extends Error {
79
+ readonly admissionFootprint: number;
80
+ readonly limit: number;
81
+
82
+ constructor(admissionFootprint: number, limit: number) {
83
+ super(`Model load queue full: admission footprint ${admissionFootprint} (waiter limit ${limit})`);
84
+ this.name = 'ModelLoadQueueFullError';
85
+ this.admissionFootprint = admissionFootprint;
86
+ this.limit = limit;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Process-local gate for native MLX work.
92
+ *
93
+ * Individual model instances already have a per-model execution mutex, but a
94
+ * lazy `loadModel()` can still run load-time materialization / warmup Metal
95
+ * work while another model is decoding. MLX's allocator and command queues are
96
+ * process-wide, so model load/swap takes an exclusive writer slot; inference
97
+ * takes shared reader slots.
98
+ */
99
+ export class ModelWorkCoordinator {
100
+ private activeReaders = 0;
101
+ private writerHeld = false;
102
+ private queuedWriters = 0;
103
+ private readonly readerWaiters: Array<() => void> = [];
104
+ private readonly writerWaiters: Array<() => void> = [];
105
+ private requestLoadAdmissions = 0;
106
+ /** Outstanding permits grouped by the requested model name. */
107
+ private readonly requestLoadAdmissionsByModel = new Map<string, Set<ModelLoadAdmissionState>>();
108
+ /**
109
+ * Resident bindings published synchronously by `ModelRegistry.register`.
110
+ * Publishing transfers every already-admitted cold request before another
111
+ * request can spend the resident budget independently.
112
+ */
113
+ private readonly residentRegistriesByModel = new Map<string, SessionRegistry>();
114
+ private readonly maxRequestLoadQueueDepth: number | undefined;
115
+ /**
116
+ * Most recent settled load bracket. Retained here because the coordinator
117
+ * is the ONE place that brackets every load: a `resolveModel` failure in
118
+ * `/v1/messages` becomes a 500 and is otherwise dropped on the floor, so a
119
+ * supervisor polling `/health` afterwards had no way to learn what went
120
+ * wrong. See {@link ModelLoadRecord} for the "successful no-op overwrites
121
+ * an earlier failure" caveat.
122
+ */
123
+ private lastLoadRecord: ModelLoadRecord | null = null;
124
+
125
+ constructor(maxRequestLoadQueueDepth?: number) {
126
+ this.maxRequestLoadQueueDepth = maxRequestLoadQueueDepth;
127
+ }
128
+
129
+ /**
130
+ * Bound requests that arrive before a model has a resident
131
+ * `SessionRegistry`. The capacity mirrors the resident FIFO: `limit`
132
+ * waiters plus one owner/runner. Callers retain the permit through every
133
+ * pre-lock await; registration synchronously transfers it into the resident
134
+ * `SessionRegistry` budget before any later arrival can spend that capacity.
135
+ */
136
+ beginRequestLoadAdmission(modelId: string): ModelLoadAdmission {
137
+ const limit = this.maxRequestLoadQueueDepth;
138
+ const admissions = this.requestLoadAdmissionsByModel.get(modelId) ?? new Set<ModelLoadAdmissionState>();
139
+ let coldFootprint = 0;
140
+ for (const admission of admissions) {
141
+ if (!admission.released && admission.coldCounted) coldFootprint += 1;
142
+ }
143
+ if (limit !== undefined && coldFootprint >= limit + 1) {
144
+ throw new ModelLoadQueueFullError(coldFootprint, limit);
145
+ }
146
+ if (!this.requestLoadAdmissionsByModel.has(modelId)) {
147
+ this.requestLoadAdmissionsByModel.set(modelId, admissions);
148
+ }
149
+ const state: ModelLoadAdmissionState = {
150
+ released: false,
151
+ coldCounted: true,
152
+ };
153
+ admissions.add(state);
154
+ this.requestLoadAdmissions += 1;
155
+ const admission: ModelLoadAdmission = {
156
+ transferToResident: (registry): PreDispatchAdmission => this.transferAdmission(state, registry),
157
+ release: (): void => {
158
+ if (state.released) return;
159
+ state.released = true;
160
+ state.residentAdmission?.release();
161
+ state.residentAdmission = undefined;
162
+ state.residentRegistry = undefined;
163
+ if (state.coldCounted) {
164
+ state.coldCounted = false;
165
+ this.requestLoadAdmissions -= 1;
166
+ if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
167
+ }
168
+ const active = this.requestLoadAdmissionsByModel.get(modelId);
169
+ active?.delete(state);
170
+ if (active?.size === 0) this.requestLoadAdmissionsByModel.delete(modelId);
171
+ },
172
+ };
173
+ const resident = this.residentRegistriesByModel.get(modelId);
174
+ if (resident) {
175
+ try {
176
+ this.transferAdmission(state, resident);
177
+ } catch {
178
+ // Preserve the failure on `state`; the handler observes it from its
179
+ // explicit transfer after resolving the binding and returns 429.
180
+ }
181
+ }
182
+ return admission;
183
+ }
184
+
185
+ /**
186
+ * Publish a requested model name's resident admission lane. Called
187
+ * synchronously from `ModelRegistry.register`, so every outstanding cold
188
+ * permit moves into the registry before a later arrival can be admitted
189
+ * against an apparently empty resident budget.
190
+ */
191
+ bindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void {
192
+ this.residentRegistriesByModel.set(modelId, registry);
193
+ const admissions = this.requestLoadAdmissionsByModel.get(modelId);
194
+ if (!admissions) return;
195
+ for (const admission of admissions) {
196
+ if (admission.released) continue;
197
+ try {
198
+ this.transferAdmission(admission, registry);
199
+ } catch {
200
+ // A cold request that cannot fit after an alias/hot-swap transition is
201
+ // marked fail-closed. Its handler will surface the stored QueueFullError
202
+ // rather than running outside either budget.
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Forget a name only when it still points at the supplied binding. */
208
+ unbindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void {
209
+ if (this.residentRegistriesByModel.get(modelId) === registry) {
210
+ this.residentRegistriesByModel.delete(modelId);
211
+ }
212
+ }
213
+
214
+ private transferAdmission(state: ModelLoadAdmissionState, registry: SessionRegistry): PreDispatchAdmission {
215
+ if (state.released) {
216
+ throw new Error('Model load admission has already been released');
217
+ }
218
+ if (state.residentRegistry === registry && state.transferError !== undefined) {
219
+ throw state.transferError;
220
+ }
221
+ if (state.residentRegistry === registry && state.residentAdmission) {
222
+ return state.residentAdmission;
223
+ }
224
+ if (state.residentAdmission) {
225
+ state.residentAdmission.release();
226
+ state.residentAdmission = undefined;
227
+ state.residentRegistry = undefined;
228
+ }
229
+ state.transferError = undefined;
230
+ try {
231
+ const residentAdmission = registry.beginPreDispatchAdmission();
232
+ state.residentRegistry = registry;
233
+ state.residentAdmission = residentAdmission;
234
+ if (state.coldCounted) {
235
+ state.coldCounted = false;
236
+ this.requestLoadAdmissions -= 1;
237
+ if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
238
+ }
239
+ return residentAdmission;
240
+ } catch (error) {
241
+ // This permit is now a rejected resident transition, not an invisible
242
+ // cold unit. Remove its cold charge; `transferToResident` rethrows the
243
+ // exact resident error when the owning request resumes.
244
+ if (state.coldCounted) {
245
+ state.coldCounted = false;
246
+ this.requestLoadAdmissions -= 1;
247
+ if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
248
+ }
249
+ state.transferError = error;
250
+ state.residentRegistry = registry;
251
+ throw error;
252
+ }
253
+ }
254
+
255
+ /** Read-only unresolved/pre-FIFO request footprint for diagnostics/tests. */
256
+ get requestLoadAdmissionCount(): number {
257
+ return this.requestLoadAdmissions;
258
+ }
259
+
260
+ /** Read-only: `true` while a load holds the exclusive writer slot. */
261
+ get writerActive(): boolean {
262
+ return this.writerHeld;
263
+ }
264
+
265
+ /** Read-only: loads parked in `acquireWrite()` waiting for the slot. */
266
+ get waitingWriters(): number {
267
+ return this.queuedWriters;
268
+ }
269
+
270
+ /** Read-only: outcome of the most recent settled load bracket, or `null`. */
271
+ get lastLoad(): ModelLoadRecord | null {
272
+ return this.lastLoadRecord;
273
+ }
274
+
275
+ /**
276
+ * Record a settled bracket. Called from the `finally` of both load
277
+ * wrappers so a throw is captured just as reliably as a success.
278
+ */
279
+ private recordLoad(label: string | undefined, startedAt: number, error: unknown, ok: boolean): void {
280
+ this.lastLoadRecord = {
281
+ label: label ?? null,
282
+ startedAt,
283
+ finishedAt: Date.now(),
284
+ ok,
285
+ error: ok ? null : describeLoadFailure(error),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * @param label Optional identifier (normally the model name) stamped into
291
+ * {@link lastLoad} so `/health` can name what was being loaded.
292
+ */
293
+ async withModelLoad<T>(fn: () => Promise<T> | T, label?: string): Promise<T> {
294
+ await this.acquireWrite();
295
+ // Measured from lock acquisition, not from arrival: `startedAt` is meant
296
+ // to answer "how long has the actual materialization been running",
297
+ // which is what a supervisor deciding whether to wait needs.
298
+ const startedAt = Date.now();
299
+ let ok = false;
300
+ let failure: unknown;
301
+ try {
302
+ const result = await fn();
303
+ ok = true;
304
+ return result;
305
+ } catch (err) {
306
+ failure = err;
307
+ throw err;
308
+ } finally {
309
+ this.recordLoad(label, startedAt, failure, ok);
310
+ this.releaseWrite();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Like {@link withModelLoad} but reports whether THIS caller owned the
316
+ * load (acquired the writer lock with no contention) or merely waited
317
+ * behind a load that was already in flight when it arrived.
318
+ *
319
+ * Decided at sync-time before any await: if neither a writer is active
320
+ * nor any writer is queued ahead, this caller is the owner; otherwise
321
+ * it is parked behind someone else's load and `owner` is `false`. The
322
+ * distinction is used by `/v1/messages` to split `resolve_ms` (own
323
+ * load + lookup) from `load_wait_ms` (waiting on a peer's load) so a
324
+ * 60-second cold-load does not look like 60 seconds of own work for
325
+ * every concurrent request.
326
+ */
327
+ async withModelLoadInstrumented<T>(fn: () => Promise<T> | T, label?: string): Promise<ModelLoadOutcome<T>> {
328
+ // `owner` MUST be decided synchronously, before any await, so the
329
+ // signal reflects coordinator state at arrival rather than after
330
+ // any peer transition. The wait/own split is measured around the
331
+ // actual phase boundaries (lock acquisition, fn completion) so the
332
+ // two intervals partition cleanly instead of both reporting total
333
+ // elapsed time — see `ModelLoadOutcome` for the contract.
334
+ const owner = !this.writerHeld && this.queuedWriters === 0;
335
+ const arrivedAt = Date.now();
336
+ await this.acquireWrite();
337
+ const lockAcquiredAt = Date.now();
338
+ let ok = false;
339
+ let failure: unknown;
340
+ try {
341
+ const result = await fn();
342
+ ok = true;
343
+ const fnDoneAt = Date.now();
344
+ const waitMs = Math.max(0, lockAcquiredAt - arrivedAt);
345
+ const ownMs = Math.max(0, fnDoneAt - lockAcquiredAt);
346
+ return { result, owner, waitMs, ownMs };
347
+ } catch (err) {
348
+ failure = err;
349
+ throw err;
350
+ } finally {
351
+ this.recordLoad(label, lockAcquiredAt, failure, ok);
352
+ this.releaseWrite();
353
+ }
354
+ }
355
+
356
+ async withInference<T>(fn: () => Promise<T> | T): Promise<T> {
357
+ await this.acquireRead();
358
+ try {
359
+ return await fn();
360
+ } finally {
361
+ this.releaseRead();
362
+ }
363
+ }
364
+
365
+ private acquireRead(): Promise<void> {
366
+ if (!this.writerHeld && this.queuedWriters === 0) {
367
+ this.activeReaders += 1;
368
+ return Promise.resolve();
369
+ }
370
+ return new Promise<void>((resolve) => {
371
+ this.readerWaiters.push(() => {
372
+ this.activeReaders += 1;
373
+ resolve();
374
+ });
375
+ });
376
+ }
377
+
378
+ private acquireWrite(): Promise<void> {
379
+ this.queuedWriters += 1;
380
+ if (!this.writerHeld && this.activeReaders === 0) {
381
+ this.queuedWriters -= 1;
382
+ this.writerHeld = true;
383
+ return Promise.resolve();
384
+ }
385
+ return new Promise<void>((resolve) => {
386
+ this.writerWaiters.push(() => {
387
+ this.queuedWriters -= 1;
388
+ this.writerHeld = true;
389
+ resolve();
390
+ });
391
+ });
392
+ }
393
+
394
+ private releaseRead(): void {
395
+ this.activeReaders -= 1;
396
+ if (this.activeReaders < 0) this.activeReaders = 0;
397
+ if (this.activeReaders === 0) this.drain();
398
+ }
399
+
400
+ private releaseWrite(): void {
401
+ this.writerHeld = false;
402
+ this.drain();
403
+ }
404
+
405
+ private drain(): void {
406
+ if (this.writerHeld) return;
407
+ if (this.activeReaders === 0 && this.writerWaiters.length > 0) {
408
+ this.writerWaiters.shift()?.();
409
+ return;
410
+ }
411
+ if (this.queuedWriters === 0 && this.readerWaiters.length > 0) {
412
+ const readers = this.readerWaiters.splice(0);
413
+ for (const resolve of readers) resolve();
414
+ }
415
+ }
416
+ }