@mlx-node/server 0.0.0

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 (61) hide show
  1. package/dist/endpoints/messages.d.ts +13 -0
  2. package/dist/endpoints/messages.d.ts.map +1 -0
  3. package/dist/endpoints/messages.js +511 -0
  4. package/dist/endpoints/models.d.ts +5 -0
  5. package/dist/endpoints/models.d.ts.map +1 -0
  6. package/dist/endpoints/models.js +10 -0
  7. package/dist/endpoints/responses.d.ts +79 -0
  8. package/dist/endpoints/responses.d.ts.map +1 -0
  9. package/dist/endpoints/responses.js +2816 -0
  10. package/dist/errors.d.ts +43 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +84 -0
  13. package/dist/handler.d.ts +18 -0
  14. package/dist/handler.d.ts.map +1 -0
  15. package/dist/handler.js +35 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +16 -0
  19. package/dist/mappers/anthropic-request.d.ts +9 -0
  20. package/dist/mappers/anthropic-request.d.ts.map +1 -0
  21. package/dist/mappers/anthropic-request.js +241 -0
  22. package/dist/mappers/anthropic-response.d.ts +14 -0
  23. package/dist/mappers/anthropic-response.d.ts.map +1 -0
  24. package/dist/mappers/anthropic-response.js +112 -0
  25. package/dist/mappers/request.d.ts +18 -0
  26. package/dist/mappers/request.d.ts.map +1 -0
  27. package/dist/mappers/request.js +206 -0
  28. package/dist/mappers/response.d.ts +13 -0
  29. package/dist/mappers/response.d.ts.map +1 -0
  30. package/dist/mappers/response.js +116 -0
  31. package/dist/pending-writes.d.ts +337 -0
  32. package/dist/pending-writes.d.ts.map +1 -0
  33. package/dist/pending-writes.js +468 -0
  34. package/dist/registry.d.ts +363 -0
  35. package/dist/registry.d.ts.map +1 -0
  36. package/dist/registry.js +497 -0
  37. package/dist/router.d.ts +6 -0
  38. package/dist/router.d.ts.map +1 -0
  39. package/dist/router.js +78 -0
  40. package/dist/server.d.ts +80 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +158 -0
  43. package/dist/session-registry.d.ts +297 -0
  44. package/dist/session-registry.d.ts.map +1 -0
  45. package/dist/session-registry.js +403 -0
  46. package/dist/streaming.d.ts +7 -0
  47. package/dist/streaming.d.ts.map +1 -0
  48. package/dist/streaming.js +16 -0
  49. package/dist/tool-call-buffer.d.ts +26 -0
  50. package/dist/tool-call-buffer.d.ts.map +1 -0
  51. package/dist/tool-call-buffer.js +51 -0
  52. package/dist/transport-visibility.d.ts +56 -0
  53. package/dist/transport-visibility.d.ts.map +1 -0
  54. package/dist/transport-visibility.js +161 -0
  55. package/dist/types-anthropic.d.ts +144 -0
  56. package/dist/types-anthropic.d.ts.map +1 -0
  57. package/dist/types-anthropic.js +2 -0
  58. package/dist/types.d.ts +220 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/package.json +36 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * ModelRegistry -- maps friendly model names to loaded model instances.
3
+ *
4
+ * All models exposing the chat-session surface (see `SessionCapableModel`
5
+ * from `@mlx-node/lm`) are eligible for serving. Every registered model
6
+ * is paired with a `SessionRegistry` — an LRU+TTL cache of live
7
+ * `ChatSession` instances keyed by server-allocated response id.
8
+ *
9
+ * **Model-instance identity, not name.** Session registries are keyed
10
+ * by MODEL OBJECT identity. The single-warm-session invariant
11
+ * enforced by `SessionRegistry` is a property of the underlying
12
+ * `SessionCapableModel` (one shared native KV cache per instance), so
13
+ * registering the SAME model object under two names MUST yield the
14
+ * SAME `SessionRegistry` — otherwise each alias's local single-warm
15
+ * cache would hand out warm wrappers while the other alias silently
16
+ * stomps them via the shared native state. `register()` looks the
17
+ * model up in an identity-keyed map and reuses the existing registry
18
+ * on alias, or allocates a fresh one on first sight.
19
+ *
20
+ * **Monotonic per-instance ids.** Every distinct model object gets a
21
+ * monotonic `instanceId` on first registration, reused across aliases,
22
+ * and dropped when the binding is fully torn down. The responses
23
+ * endpoint persists this id alongside each stored record and, on a
24
+ * `previous_response_id` continuation, compares the stored id against
25
+ * the live id for `body.model`. This closes two holes a friendly-name
26
+ * check leaves open:
27
+ *
28
+ * 1. A name hot-swap — `register("foo", modelA)` then
29
+ * `register("foo", modelB)` — would pass a string check, so a
30
+ * chain produced by `modelA` could be silently replayed through
31
+ * `modelB`'s tokenizer / chat template / KV layout. With instance
32
+ * ids the stored id (modelA's) no longer matches the live id
33
+ * (modelB's) and the continuation is rejected with 400.
34
+ * 2. Two NAMES aliasing the SAME model object would be spuriously
35
+ * rejected by a string check comparing the stored name against
36
+ * `body.model`. Instance ids recognise them as the same binding
37
+ * and the continuation is accepted.
38
+ */
39
+ import { SessionRegistry } from './session-registry.js';
40
+ export class ModelRegistry {
41
+ maxQueueDepth;
42
+ models = new Map();
43
+ /**
44
+ * Identity-keyed (WeakMap semantics, but strong refs because the
45
+ * registry already holds the model through its ModelEntry) map
46
+ * from a model instance to its shared `SessionRegistry` binding.
47
+ * Every name that references the same model object resolves to
48
+ * the same binding — an alias of a registered model shares its
49
+ * session cache and therefore its single-warm invariant.
50
+ */
51
+ sessionRegistriesByModel = new Map();
52
+ /**
53
+ * Identity-keyed map from a model instance to its monotonic
54
+ * instance id. Entries are allocated on first registration,
55
+ * reused across aliasing, and dropped when the last binding
56
+ * releases (mirrors `sessionRegistriesByModel` lifetime exactly).
57
+ */
58
+ instanceIds = new Map();
59
+ /** Monotonic counter for `instanceIds`. Never reused. */
60
+ nextInstanceId = 1;
61
+ /**
62
+ * Tombstone map for instance ids retired by the hard-timeout
63
+ * breaker. When the responses endpoint force-releases a wedged
64
+ * persist's `retainBinding`, the breaker calls
65
+ * `retireInstanceIdForForceRelease(model)` BEFORE dropping the
66
+ * retain so the live id (already stamped into the pending record)
67
+ * is preserved here. A subsequent `register()` of the SAME model
68
+ * object that arrives AFTER the binding has fully torn down
69
+ * inherits the retired id instead of minting a fresh one — so a
70
+ * late-landing persist's row stays chainable. A true hot-swap
71
+ * (different model object) has no tombstone for the new model,
72
+ * so a fresh id is minted and the stale stored row is correctly
73
+ * rejected with 400 instance-mismatch.
74
+ *
75
+ * `WeakMap`-keyed on the model object so entries do not keep the
76
+ * model alive; if the model is GC'd the tombstone is cleaned up
77
+ * automatically.
78
+ *
79
+ * Lifetime is refcounted: store ONE `{ instanceId, outstandingCount }`
80
+ * entry per model. `retireInstanceIdForForceRelease` increments
81
+ * (creating the entry on first retire); `releaseTombstone`
82
+ * decrements and drops the entry when count hits zero. Because
83
+ * `register()` inherits the retired id whenever the tombstone
84
+ * exists, concurrent breakers on the same model all target the
85
+ * SAME numeric `instanceId` — one shared refcount keeps the
86
+ * tombstone alive as long as ANY pending persist still needs it,
87
+ * and memory is bounded at O(1) per model regardless of how many
88
+ * hard-timeouts have fired.
89
+ */
90
+ retiredInstanceIds = new WeakMap();
91
+ constructor(opts) {
92
+ this.maxQueueDepth = opts?.maxQueueDepth;
93
+ }
94
+ /**
95
+ * Register a model under a given name.
96
+ *
97
+ * If the name is already registered and the new model is a
98
+ * DIFFERENT instance, the old binding's refcount is decremented
99
+ * (and dropped if no other alias references it) before the new
100
+ * binding is taken. Re-registering with the SAME model instance
101
+ * leaves the binding unchanged.
102
+ *
103
+ * On first sight of a model object a fresh `SessionRegistry` is
104
+ * allocated. On alias the existing registry is reused so the
105
+ * single-warm invariant spans both names.
106
+ *
107
+ * Tombstone-inherit path: if the binding was previously torn down
108
+ * AND the hard-timeout breaker called
109
+ * `retireInstanceIdForForceRelease(model)` before teardown fired,
110
+ * the fresh binding inherits the retired instance id from
111
+ * `retiredInstanceIds` — so a late-landing persist's record stays
112
+ * chainable. A hot-swap (different model object) has no tombstone,
113
+ * so a fresh id is minted and the stale record fails
114
+ * `previous_response_id` with 400. The aliasing path naturally
115
+ * preserves the id because `instanceIds.has(model)` is already
116
+ * true.
117
+ */
118
+ register(name, model) {
119
+ const existing = this.models.get(name);
120
+ if (existing && existing.model === model) {
121
+ // Same name + same model object: leave the binding and refcount
122
+ // alone. Refresh createdAt so `/v1/models` surfaces the most
123
+ // recent registration time.
124
+ existing.createdAt = Math.floor(Date.now() / 1000);
125
+ return;
126
+ }
127
+ if (existing) {
128
+ // Same name, different model: release the old model's refcount
129
+ // before installing the new binding.
130
+ this.dropNameReference(existing.model);
131
+ }
132
+ // Look up or allocate the shared binding. If it is still alive
133
+ // but flagged `pendingTeardown`, clear the flag and reuse it —
134
+ // the fresh registration revives the binding before teardown
135
+ // runs so the shared `SessionRegistry` / mutex chain stays
136
+ // identical and any new dispatch serializes behind the
137
+ // in-flight one.
138
+ let binding = this.sessionRegistriesByModel.get(model);
139
+ if (!binding) {
140
+ binding = {
141
+ registry: new SessionRegistry({ model, maxQueueDepth: this.maxQueueDepth }),
142
+ refCount: 0,
143
+ inFlight: 0,
144
+ pendingPersists: 0,
145
+ pendingTeardown: false,
146
+ };
147
+ this.sessionRegistriesByModel.set(model, binding);
148
+ }
149
+ else if (binding.pendingTeardown) {
150
+ binding.pendingTeardown = false;
151
+ }
152
+ binding.refCount += 1;
153
+ // Allocate a fresh monotonic instance id on first sight of this
154
+ // model object; reuse the existing id on every alias thereafter.
155
+ // Id lifetime mirrors the binding's — see `finalizeBindingTeardown`.
156
+ // If the binding was fully torn down but the hard-timeout breaker
157
+ // retired the previous id for the same model object, inherit it
158
+ // from the tombstone instead of minting fresh.
159
+ if (!this.instanceIds.has(model)) {
160
+ // Tombstone is refcounted; we do NOT decrement here — the
161
+ // still-pending persists own the outstanding count and balance
162
+ // it via `releaseTombstone` in their own `.finally(...)`.
163
+ const tombstone = this.retiredInstanceIds.get(model);
164
+ if (tombstone) {
165
+ this.instanceIds.set(model, tombstone.instanceId);
166
+ }
167
+ else {
168
+ this.instanceIds.set(model, this.nextInstanceId);
169
+ this.nextInstanceId += 1;
170
+ }
171
+ }
172
+ this.models.set(name, {
173
+ id: name,
174
+ model,
175
+ createdAt: Math.floor(Date.now() / 1000),
176
+ sessionRegistry: binding.registry,
177
+ });
178
+ }
179
+ /**
180
+ * Unregister a model by name.
181
+ *
182
+ * Drops the name -> ModelEntry mapping and decrements the shared
183
+ * session-registry binding's refcount. When the refcount hits zero
184
+ * (no other alias references this model object) the binding — and
185
+ * the `SessionRegistry` it owns — is dropped entirely so cached
186
+ * sessions for the now-unreferenced model are released.
187
+ *
188
+ * @returns true if the model was removed.
189
+ */
190
+ unregister(name) {
191
+ const entry = this.models.get(name);
192
+ if (!entry)
193
+ return false;
194
+ this.models.delete(name);
195
+ this.dropNameReference(entry.model);
196
+ return true;
197
+ }
198
+ /**
199
+ * Decrement the refcount on a model binding; drop it at zero iff
200
+ * no dispatch holds a lease AND no post-commit persist is still
201
+ * retaining it. When either counter is non-zero the teardown is
202
+ * deferred via `pendingTeardown` so the `SessionRegistry` (and
203
+ * its `execLock` FIFO) stays alive until the last holder releases.
204
+ *
205
+ * A concurrent `register(sameModel)` between `dropNameReference()`
206
+ * and the final release clears `pendingTeardown` and reuses the
207
+ * still-live binding, preserving `modelInstanceId` so any row the
208
+ * pending persist is about to land still resolves to a live id
209
+ * when the next continuation arrives.
210
+ */
211
+ dropNameReference(model) {
212
+ const binding = this.sessionRegistriesByModel.get(model);
213
+ if (!binding)
214
+ return;
215
+ binding.refCount -= 1;
216
+ if (binding.refCount <= 0) {
217
+ if (binding.inFlight > 0 || binding.pendingPersists > 0) {
218
+ // Defer until the last lease AND the last persist retention
219
+ // drop. The binding and its instance id stay in the maps so
220
+ // a same-object re-registration before finalisation can
221
+ // revive it in place.
222
+ binding.pendingTeardown = true;
223
+ return;
224
+ }
225
+ this.finalizeBindingTeardown(model);
226
+ }
227
+ }
228
+ /**
229
+ * Drop `model`'s binding and instance id from the registry.
230
+ * Shared teardown step; a subsequent re-registration usually mints
231
+ * a FRESH instance id — once the last alias, lease, and persist
232
+ * retention all release, any previously stored record referencing
233
+ * this id belongs to a logically dead binding and a continuation
234
+ * against it must fall through to the `currentInstanceId === undefined`
235
+ * rejection path so the stale chain cannot be replayed.
236
+ *
237
+ * Tombstone exception: if the hard-timeout breaker retired the
238
+ * previous id via `retireInstanceIdForForceRelease` before the
239
+ * forced release, a subsequent same-object `register()` inherits
240
+ * the retired id from `retiredInstanceIds` instead of minting
241
+ * fresh — this preserves chain continuity for late-landing
242
+ * persists that crossed the safety breaker.
243
+ */
244
+ finalizeBindingTeardown(model) {
245
+ this.sessionRegistriesByModel.delete(model);
246
+ this.instanceIds.delete(model);
247
+ }
248
+ /**
249
+ * Acquire a dispatch lease on the session registry bound to `name`.
250
+ * Returns the live `SessionRegistry` and the binding's instance id,
251
+ * or `undefined` if the name is not registered. Every successful
252
+ * acquisition MUST be balanced with exactly one
253
+ * `releaseDispatchLease(model)` call (typically via try/finally).
254
+ *
255
+ * The lease keeps the binding alive past a concurrent
256
+ * `unregister()` / `register(differentModel)` sequence: the
257
+ * `SessionRegistry` and its `execLock` FIFO remain valid while any
258
+ * lease is outstanding, so a newly registered same-model alias
259
+ * will rebind to the SAME registry and its `withExclusive` will
260
+ * serialize behind the in-flight dispatch.
261
+ *
262
+ * The returned `model` handle is what the caller passes to
263
+ * `releaseDispatchLease()` — the lease binds to the model OBJECT
264
+ * (not the friendly name) because the name can be hot-swapped
265
+ * while the lease is held.
266
+ */
267
+ acquireDispatchLease(name) {
268
+ const entry = this.models.get(name);
269
+ if (!entry)
270
+ return undefined;
271
+ const binding = this.sessionRegistriesByModel.get(entry.model);
272
+ if (!binding)
273
+ return undefined;
274
+ const instanceId = this.instanceIds.get(entry.model);
275
+ if (instanceId === undefined)
276
+ return undefined;
277
+ binding.inFlight += 1;
278
+ return { model: entry.model, registry: binding.registry, instanceId };
279
+ }
280
+ /**
281
+ * Release a dispatch lease previously obtained via
282
+ * `acquireDispatchLease()`. Decrements the binding's in-flight
283
+ * counter and, if the binding has been flagged for teardown (its
284
+ * refcount hit zero while the lease was held), drops it once the
285
+ * last lease releases. Safe to call exactly once per acquired
286
+ * lease; calling it on a model whose binding has already been
287
+ * fully torn down is a no-op.
288
+ */
289
+ releaseDispatchLease(model) {
290
+ const binding = this.sessionRegistriesByModel.get(model);
291
+ if (!binding)
292
+ return;
293
+ binding.inFlight -= 1;
294
+ if (binding.inFlight < 0)
295
+ binding.inFlight = 0;
296
+ if (binding.pendingTeardown && binding.refCount <= 0 && binding.inFlight === 0 && binding.pendingPersists === 0) {
297
+ this.finalizeBindingTeardown(model);
298
+ }
299
+ }
300
+ /**
301
+ * Retain the binding for the duration of a post-commit persist.
302
+ *
303
+ * The responses endpoint starts `store.store(record)` synchronously
304
+ * inside `withExclusive` so the pending-writes tracker observes
305
+ * the in-flight write before the mutex releases, but does NOT
306
+ * await it on the critical path. The write still carries the
307
+ * binding's `modelInstanceId` (stamped into `configJson` by
308
+ * `buildResponseRecord`); without a retention, a same-model
309
+ * unregister + re-register completing while the write is in flight
310
+ * would delete the instance id and the re-registration would mint
311
+ * a fresh one, so the row — when it finally lands — would
312
+ * reference a dead id and the next continuation would be rejected
313
+ * with 400 instance-mismatch.
314
+ *
315
+ * The retention counter is CHECKED in every teardown gate
316
+ * (`dropNameReference`, `releaseDispatchLease`, `releaseBinding`).
317
+ * It is orthogonal to `inFlight` so the dispatch lease can release
318
+ * eagerly after `withExclusive` returns while the binding stays
319
+ * pinned long enough for the backgrounded `store.store(...)` to
320
+ * settle.
321
+ *
322
+ * Safe to call on a model whose binding has already been torn down
323
+ * (no-op). The matching `releaseBinding(model)` MUST still run in
324
+ * the persist's `.finally(...)` so the counter stays balanced.
325
+ */
326
+ retainBinding(model) {
327
+ const binding = this.sessionRegistriesByModel.get(model);
328
+ if (!binding)
329
+ return;
330
+ binding.pendingPersists += 1;
331
+ }
332
+ /**
333
+ * Balance a prior `retainBinding()` call. Decrements the persist
334
+ * retention counter and, if the binding has been flagged for
335
+ * teardown (refcount hit zero while the retention was held AND
336
+ * every dispatch lease has already released), drops it once the
337
+ * last retention releases. Safe to call exactly once per retain;
338
+ * calling it on a model whose binding has already been fully torn
339
+ * down is a no-op.
340
+ */
341
+ releaseBinding(model) {
342
+ const binding = this.sessionRegistriesByModel.get(model);
343
+ if (!binding)
344
+ return;
345
+ binding.pendingPersists -= 1;
346
+ if (binding.pendingPersists < 0)
347
+ binding.pendingPersists = 0;
348
+ if (binding.pendingTeardown && binding.refCount <= 0 && binding.inFlight === 0 && binding.pendingPersists === 0) {
349
+ this.finalizeBindingTeardown(model);
350
+ }
351
+ }
352
+ /**
353
+ * Tombstone installer, invoked exclusively by the responses
354
+ * endpoint's hard-timeout breaker (see
355
+ * `getPostCommitPersistHardTimeoutMs` in `endpoints/responses.ts`)
356
+ * when it force-releases the `retainBinding` on a wedged persist.
357
+ * Must be called BEFORE the idempotent
358
+ * `persistRetainBox.release?.()` so `instanceIds.get(model)`
359
+ * still returns the live id that the already-stamped record
360
+ * carries.
361
+ *
362
+ * Returns the retired id so the caller can capture it and scope
363
+ * the tombstone's lifetime to the specific wedged persist via
364
+ * `releaseTombstone(model)` inside that persist's `.finally(...)`.
365
+ * Returns `undefined` when the model has no current instance id
366
+ * assignment (caller raced the natural teardown path).
367
+ *
368
+ * Refcounted: each call increments a shared
369
+ * `{ instanceId, outstandingCount }` entry per model. Overlapping
370
+ * breakers share one slot (they all target the same numeric id
371
+ * because `register()` inherits the retired id whenever the
372
+ * tombstone is present), so memory stays O(1) per model.
373
+ */
374
+ retireInstanceIdForForceRelease(model) {
375
+ const id = this.instanceIds.get(model);
376
+ if (id === undefined)
377
+ return undefined;
378
+ const existing = this.retiredInstanceIds.get(model);
379
+ if (existing) {
380
+ existing.outstandingCount += 1;
381
+ return { instanceId: existing.instanceId };
382
+ }
383
+ this.retiredInstanceIds.set(model, { instanceId: id, outstandingCount: 1 });
384
+ return { instanceId: id };
385
+ }
386
+ /**
387
+ * Tombstone cleanup. Called from the post-commit persist's
388
+ * `.finally(...)` to balance exactly one prior
389
+ * `retireInstanceIdForForceRelease(model)` call. Decrements the
390
+ * shared refcount and drops the entry at zero so the next natural
391
+ * teardown mints a fresh id.
392
+ *
393
+ * Safe to call on a model whose tombstone has already been drained
394
+ * (no-op). The counter is clamped non-negative so spurious
395
+ * releases cannot underflow and re-enable inheritance.
396
+ */
397
+ releaseTombstone(model) {
398
+ const entry = this.retiredInstanceIds.get(model);
399
+ if (!entry)
400
+ return;
401
+ entry.outstandingCount -= 1;
402
+ if (entry.outstandingCount <= 0) {
403
+ this.retiredInstanceIds.delete(model);
404
+ }
405
+ }
406
+ /**
407
+ * Retrieve a model instance by name.
408
+ */
409
+ get(name) {
410
+ return this.models.get(name)?.model;
411
+ }
412
+ /**
413
+ * Retrieve the monotonic instance id for the model currently bound
414
+ * to `name`, or `undefined` if the name isn't registered.
415
+ *
416
+ * Two names that alias the same model object return the SAME id
417
+ * (they share a binding), and a name that has been hot-swapped to
418
+ * a different model object returns a DIFFERENT id than before the
419
+ * swap (the prior binding's id was dropped by `dropNameReference`
420
+ * and a fresh id was minted for the new model on re-registration).
421
+ *
422
+ * The responses endpoint uses this to key the
423
+ * `previous_response_id` cross-chain guard on instance identity
424
+ * instead of friendly name, so hot swaps are caught and safe
425
+ * aliases are not spuriously rejected.
426
+ */
427
+ getInstanceId(name) {
428
+ const entry = this.models.get(name);
429
+ if (!entry)
430
+ return undefined;
431
+ return this.instanceIds.get(entry.model);
432
+ }
433
+ /**
434
+ * Retrieve the session registry for a given model name, or
435
+ * `undefined` if the name is not registered.
436
+ *
437
+ * Every name that points at the same model instance returns the
438
+ * SAME `SessionRegistry` object. Two aliases `a` and `b` of one
439
+ * model therefore satisfy
440
+ * `registry.getSessionRegistry('a') === registry.getSessionRegistry('b')`,
441
+ * which is what the single-warm invariant requires: any turn
442
+ * through either alias advances the same cache's state, so a later
443
+ * lookup via either alias sees the current warm wrapper (if
444
+ * freshly adopted) or misses and cold-replays (if it was leased
445
+ * out by the other alias) — never a stale wrapper pointing at
446
+ * stomped native state.
447
+ */
448
+ getSessionRegistry(name) {
449
+ return this.models.get(name)?.sessionRegistry;
450
+ }
451
+ /**
452
+ * Iterate every DISTINCT session registry currently in use.
453
+ *
454
+ * Two aliases of the same model share one `SessionRegistry`, so
455
+ * naively walking every `ModelEntry` would yield duplicates. We
456
+ * walk the identity-keyed bindings instead so each registry
457
+ * appears exactly once, which is what the periodic `sweep()`
458
+ * scheduler in `server.ts` needs to avoid redundantly sweeping
459
+ * the same cache multiple times per tick.
460
+ */
461
+ listSessionRegistries() {
462
+ const out = [];
463
+ for (const binding of this.sessionRegistriesByModel.values()) {
464
+ out.push(binding.registry);
465
+ }
466
+ return out;
467
+ }
468
+ /**
469
+ * List all registered models in the OpenAI /v1/models format.
470
+ */
471
+ list() {
472
+ const result = [];
473
+ for (const entry of this.models.values()) {
474
+ result.push({
475
+ id: entry.id,
476
+ object: 'model',
477
+ created: entry.createdAt,
478
+ owned_by: 'mlx-node',
479
+ });
480
+ }
481
+ return result;
482
+ }
483
+ /**
484
+ * Check whether a model supports streaming.
485
+ *
486
+ * Every `SessionCapableModel` structurally exposes
487
+ * `chatStreamSessionStart`, so this is universally `true` for any
488
+ * properly-typed model registered through the session-capable
489
+ * interface. Kept as a belt-and-suspenders duck-type so a partially
490
+ * stubbed test double (pre-migration or intentionally non-streaming)
491
+ * can still opt out by omitting the method.
492
+ */
493
+ hasStreamSupport(model) {
494
+ const fn = model['chatStreamSessionStart'];
495
+ return typeof fn === 'function';
496
+ }
497
+ }
@@ -0,0 +1,6 @@
1
+ /** Path-based router for /v1/* endpoints. */
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { ResponseStore } from '@mlx-node/core';
4
+ import type { ModelRegistry } from './registry.js';
5
+ export declare function routeRequest(req: IncomingMessage, res: ServerResponse, registry: ModelRegistry, store: ResponseStore | null, responseRetentionSec?: number): Promise<void>;
6
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAE7C,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAyBnD,wBAAsB,YAAY,CAChC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GAAG,IAAI,EAC3B,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAAC,IAAI,CAAC,CA8Df"}
package/dist/router.js ADDED
@@ -0,0 +1,78 @@
1
+ /** Path-based router for /v1/* endpoints. */
2
+ import { handleCreateMessage } from './endpoints/messages.js';
3
+ import { handleListModels } from './endpoints/models.js';
4
+ import { handleCreateResponse } from './endpoints/responses.js';
5
+ import { sendAnthropicBadRequest, sendAnthropicMethodNotAllowed, sendBadRequest, sendMethodNotAllowed, sendNotFound, } from './errors.js';
6
+ /** Max request body size (10 MB). */
7
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
8
+ function readBody(req) {
9
+ return new Promise((resolve, reject) => {
10
+ const chunks = [];
11
+ let totalBytes = 0;
12
+ req.on('data', (chunk) => {
13
+ totalBytes += chunk.length;
14
+ if (totalBytes > MAX_BODY_BYTES) {
15
+ reject(new Error('Request body too large'));
16
+ req.destroy();
17
+ return;
18
+ }
19
+ chunks.push(chunk);
20
+ });
21
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
22
+ req.on('error', reject);
23
+ });
24
+ }
25
+ export async function routeRequest(req, res, registry, store, responseRetentionSec) {
26
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
27
+ const path = url.pathname;
28
+ if (path === '/v1/models') {
29
+ if (req.method !== 'GET') {
30
+ sendMethodNotAllowed(res, 'GET');
31
+ return;
32
+ }
33
+ handleListModels(res, registry);
34
+ return;
35
+ }
36
+ if (path === '/v1/responses') {
37
+ if (req.method !== 'POST') {
38
+ sendMethodNotAllowed(res, 'POST');
39
+ return;
40
+ }
41
+ let body;
42
+ try {
43
+ const raw = await readBody(req);
44
+ body = JSON.parse(raw);
45
+ }
46
+ catch (err) {
47
+ const msg = err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
48
+ sendBadRequest(res, msg);
49
+ return;
50
+ }
51
+ await handleCreateResponse(res, body, registry, store, req, responseRetentionSec);
52
+ return;
53
+ }
54
+ if (path === '/v1/messages') {
55
+ if (req.method !== 'POST') {
56
+ sendAnthropicMethodNotAllowed(res, 'POST');
57
+ return;
58
+ }
59
+ let body;
60
+ try {
61
+ const raw = await readBody(req);
62
+ body = JSON.parse(raw);
63
+ }
64
+ catch (err) {
65
+ const msg = err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
66
+ sendAnthropicBadRequest(res, msg);
67
+ return;
68
+ }
69
+ await handleCreateMessage(res, body, registry, req);
70
+ return;
71
+ }
72
+ if (path === '/health' || path === '/v1/health') {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify({ status: 'ok' }));
75
+ return;
76
+ }
77
+ sendNotFound(res, `No route matches ${req.method} ${path}`);
78
+ }
@@ -0,0 +1,80 @@
1
+ /** Full HTTP server lifecycle: wires up the handler and periodically sweeps expired `ResponseStore` rows and sessions. */
2
+ import type { Server } from 'node:http';
3
+ import { ResponseStore } from '@mlx-node/core';
4
+ import { ModelRegistry } from './registry.js';
5
+ /**
6
+ * Parse a positive integer seconds value; returns undefined for unset/invalid so caller can apply its own default.
7
+ *
8
+ * Non-integer positive values (e.g. `"1.5"`) are rejected rather than
9
+ * silently truncated — a typo like `"1.5"` meant as `"15"` would otherwise
10
+ * be accepted as 1 second, expiring persisted response rows almost
11
+ * immediately and breaking `previous_response_id` continuity. We prefer
12
+ * falling through to the caller's default over crashing on startup so a
13
+ * config-template typo in a Dockerfile / CI manifest does not take the
14
+ * service down.
15
+ *
16
+ * Exported for unit tests.
17
+ */
18
+ export declare function parseEnvSeconds(name: string): number | undefined;
19
+ /**
20
+ * Parse a positive integer count from env; shares the reject-unset-or-invalid
21
+ * semantics used by {@link parseEnvSeconds} (including the non-integer
22
+ * reject) so callers can fall back to their own default when the var is
23
+ * missing or malformed.
24
+ *
25
+ * Exported for unit tests.
26
+ */
27
+ export declare function parseEnvPositiveInt(name: string): number | undefined;
28
+ export interface ServerConfig {
29
+ /** Port to listen on (default: 8080). */
30
+ port?: number;
31
+ /** Hostname to bind to (default: '127.0.0.1'). */
32
+ host?: string;
33
+ /** Path to the SQLite response store (default: ~/.mlx-node/responses.db). */
34
+ storePath?: string;
35
+ /** Disable response storage entirely (default: false). */
36
+ disableStore?: boolean;
37
+ /** Enable CORS headers (default: true). */
38
+ cors?: boolean;
39
+ /**
40
+ * Retention for persisted response rows, in seconds. Stamped as `expires_at`
41
+ * on each committed response; controls how long `previous_response_id`
42
+ * cold-replay from SQLite remains possible after the warm session is evicted.
43
+ *
44
+ * Default: 7 days. Env override: `MLX_RESPONSE_RETENTION_SECONDS`. Ignored
45
+ * when `disableStore` is true.
46
+ */
47
+ responseRetentionSec?: number;
48
+ /**
49
+ * Maximum number of concurrent requests that may be WAITING for the
50
+ * per-model execution mutex (the one actively running does not count).
51
+ * When the cap is reached, further requests return HTTP 429 with a
52
+ * `Retry-After: 1` header so clients can back off instead of piling
53
+ * into an unbounded queue.
54
+ *
55
+ * Default: `undefined` (unbounded — current behaviour). Env override:
56
+ * `MLX_MAX_QUEUE_DEPTH_PER_MODEL` (positive integer).
57
+ */
58
+ maxQueueDepthPerModel?: number;
59
+ }
60
+ export interface ServerInstance {
61
+ server: Server;
62
+ /** Register models before or after starting. */
63
+ registry: ModelRegistry;
64
+ /** Null when disabled. */
65
+ store: ResponseStore | null;
66
+ /** Graceful shutdown. */
67
+ close(): Promise<void>;
68
+ }
69
+ /**
70
+ * Start an MLX-Node HTTP server exposing `POST /v1/responses`,
71
+ * `POST /v1/messages`, and `GET /v1/models`.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const { registry, close } = await createServer({ port: 8080 });
76
+ * registry.register('qwen3.5-3b', await Qwen35Model.load('./models/qwen3.5-3b'));
77
+ * ```
78
+ */
79
+ export declare function createServer(config?: ServerConfig): Promise<ServerInstance>;
80
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,0HAA0H;AAI1H,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAIxC,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG/C,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAgB9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOhE;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOpE;AAuBD,MAAM,WAAW,YAAY;IAC3B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,QAAQ,EAAE,aAAa,CAAC;IACxB,0BAA0B;IAC1B,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,yBAAyB;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAoEjF"}