@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,691 @@
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
+
40
+ import type { ChatConfig } from '@mlx-node/core';
41
+ import type { SessionCapableModel } from '@mlx-node/lm';
42
+
43
+ import { SessionRegistry } from './session-registry.js';
44
+
45
+ interface ModelLoadAdmissionCoordinator {
46
+ bindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void;
47
+ unbindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void;
48
+ }
49
+
50
+ function concurrentDispatchCapacity(model: ServableModel): number {
51
+ if (model.hasBlockPagedCache?.() !== true) return 1;
52
+ const reported = model.maxConcurrentSequences?.();
53
+ if (reported === undefined || !Number.isSafeInteger(reported) || reported < 2) return 1;
54
+ return reported;
55
+ }
56
+
57
+ /** Minimal contract for a model that can be served via chat sessions. */
58
+ export type ServableModel = SessionCapableModel;
59
+
60
+ /** Model entry stored in the registry. */
61
+ export interface ModelEntry {
62
+ id: string;
63
+ model: ServableModel;
64
+ createdAt: number;
65
+ /**
66
+ * Per-model-instance session cache, shared across every name that
67
+ * points at this exact model object. See the module-level rustdoc.
68
+ */
69
+ sessionRegistry: SessionRegistry;
70
+ }
71
+
72
+ /**
73
+ * Refcounted binding between a `ServableModel` and its shared
74
+ * `SessionRegistry`. One binding per distinct model object currently
75
+ * referenced by at least one registered name.
76
+ *
77
+ * - `refCount` tracks how many names point at this binding so
78
+ * `unregister()` can drop it once the last alias goes away.
79
+ * - `inFlight` tracks dispatches currently holding the binding via
80
+ * `acquireDispatchLease()`. Teardown must not tear the binding
81
+ * down while any lease is held — otherwise an unregister +
82
+ * re-register of the SAME model mid-dispatch would allocate a
83
+ * FRESH `SessionRegistry` with an empty `execLock` chain and
84
+ * concurrent requests would race on the same native model.
85
+ * Teardown is deferred via `pendingTeardown`; a `register()` that
86
+ * sees the flag clears it and reuses the still-live binding so
87
+ * the fresh request's mutex chain serializes behind the in-flight
88
+ * dispatch on one shared `execLock`.
89
+ * - `pendingPersists` tracks post-commit persist writes still
90
+ * in-flight under this binding's instance identity. Orthogonal to
91
+ * `inFlight` so the dispatch lease can be released eagerly once
92
+ * `withExclusive` returns (a wedged `store.store(...)` cannot pin
93
+ * the request's abort listeners / lease) while the binding's
94
+ * `modelInstanceId` still stays valid until every row it stamped
95
+ * has durably landed. `finalizeBindingTeardown` requires
96
+ * `pendingPersists === 0` so a same-model unregister + re-register
97
+ * during a slow persist cannot mint a fresh `modelInstanceId` that
98
+ * would invalidate the row the persist is about to land.
99
+ */
100
+ interface SessionRegistryBinding {
101
+ registry: SessionRegistry;
102
+ refCount: number;
103
+ inFlight: number;
104
+ pendingPersists: number;
105
+ pendingTeardown: boolean;
106
+ }
107
+
108
+ /**
109
+ * Constructor options for {@link ModelRegistry}.
110
+ */
111
+ export interface ModelRegistryOptions {
112
+ /**
113
+ * Maximum queue depth (waiters-only) per-model for the session
114
+ * registry's execution mutex. Forwarded into every
115
+ * `SessionRegistry` this registry allocates. See
116
+ * {@link SessionRegistryOptions.maxQueueDepth}. Default: `undefined`
117
+ * (unbounded — current behaviour).
118
+ */
119
+ maxQueueDepth?: number;
120
+ }
121
+
122
+ /**
123
+ * Per-registration options for {@link ModelRegistry.register}.
124
+ */
125
+ export interface RegisterOptions {
126
+ /**
127
+ * Per-model sampling defaults forwarded through the bound
128
+ * `SessionRegistry` into every `ChatSession` it allocates (as the
129
+ * session's `defaultConfig`). Clients' per-request sampling values
130
+ * (OpenAI `temperature`/`top_p`, Anthropic equivalents) still win
131
+ * where present because `ChatSession.mergeConfig` treats them as an
132
+ * overlay — these defaults only fill the gaps for parameters the
133
+ * client never sent (`top_k`, `min_p`, penalties, etc.).
134
+ *
135
+ * Re-registering the same name with a fresh `samplingDefaults` value
136
+ * overwrites the binding's defaults in place so the next
137
+ * `ChatSession` allocated out of the registry picks up the new
138
+ * values. Warm sessions already in flight keep the previous defaults
139
+ * until they settle; this matches how the refresh path treats other
140
+ * per-binding state.
141
+ */
142
+ samplingDefaults?: ChatConfig;
143
+ /**
144
+ * Optional per-model upper bound for generated output tokens. This is
145
+ * intentionally separate from `samplingDefaults.maxNewTokens`: client
146
+ * requests still provide the desired length, while endpoint handlers can
147
+ * clamp pathological values before dispatch.
148
+ */
149
+ maxOutputTokens?: number;
150
+ }
151
+
152
+ export class ModelRegistry {
153
+ private readonly maxQueueDepth: number | undefined;
154
+ private readonly models = new Map<string, ModelEntry>();
155
+ /**
156
+ * Identity-keyed (WeakMap semantics, but strong refs because the
157
+ * registry already holds the model through its ModelEntry) map
158
+ * from a model instance to its shared `SessionRegistry` binding.
159
+ * Every name that references the same model object resolves to
160
+ * the same binding — an alias of a registered model shares its
161
+ * session cache and therefore its single-warm invariant.
162
+ */
163
+ private readonly sessionRegistriesByModel = new Map<ServableModel, SessionRegistryBinding>();
164
+ /**
165
+ * Identity-keyed map from a model instance to its monotonic
166
+ * instance id. Entries are allocated on first registration,
167
+ * reused across aliasing, and dropped when the last binding
168
+ * releases (mirrors `sessionRegistriesByModel` lifetime exactly).
169
+ */
170
+ private readonly instanceIds = new Map<ServableModel, number>();
171
+ /** Monotonic counter for `instanceIds`. Never reused. */
172
+ private nextInstanceId = 1;
173
+ /**
174
+ * Tombstone map for instance ids retired by the hard-timeout
175
+ * breaker. When the responses endpoint force-releases a wedged
176
+ * persist's `retainBinding`, the breaker calls
177
+ * `retireInstanceIdForForceRelease(model)` BEFORE dropping the
178
+ * retain so the live id (already stamped into the pending record)
179
+ * is preserved here. A subsequent `register()` of the SAME model
180
+ * object that arrives AFTER the binding has fully torn down
181
+ * inherits the retired id instead of minting a fresh one — so a
182
+ * late-landing persist's row stays chainable. A true hot-swap
183
+ * (different model object) has no tombstone for the new model,
184
+ * so a fresh id is minted and the stale stored row is correctly
185
+ * rejected with 400 instance-mismatch.
186
+ *
187
+ * `WeakMap`-keyed on the model object so entries do not keep the
188
+ * model alive; if the model is GC'd the tombstone is cleaned up
189
+ * automatically.
190
+ *
191
+ * Lifetime is refcounted: store ONE `{ instanceId, outstandingCount }`
192
+ * entry per model. `retireInstanceIdForForceRelease` increments
193
+ * (creating the entry on first retire); `releaseTombstone`
194
+ * decrements and drops the entry when count hits zero. Because
195
+ * `register()` inherits the retired id whenever the tombstone
196
+ * exists, concurrent breakers on the same model all target the
197
+ * SAME numeric `instanceId` — one shared refcount keeps the
198
+ * tombstone alive as long as ANY pending persist still needs it,
199
+ * and memory is bounded at O(1) per model regardless of how many
200
+ * hard-timeouts have fired.
201
+ */
202
+ private readonly retiredInstanceIds = new WeakMap<ServableModel, { instanceId: number; outstandingCount: number }>();
203
+ private modelLoadAdmissionCoordinator: ModelLoadAdmissionCoordinator | undefined;
204
+
205
+ constructor(opts?: ModelRegistryOptions) {
206
+ this.maxQueueDepth = opts?.maxQueueDepth;
207
+ }
208
+
209
+ /** Configured per-model waiter cap, or `undefined` when unbounded. */
210
+ get queueDepthLimit(): number | undefined {
211
+ return this.maxQueueDepth;
212
+ }
213
+
214
+ /**
215
+ * Connect cold-load admission to name registration. The coordinator is
216
+ * attached by the HTTP handler before dispatch; existing names are
217
+ * published immediately and future `register`/`unregister` calls keep the
218
+ * mapping current.
219
+ */
220
+ setModelLoadAdmissionCoordinator(coordinator: ModelLoadAdmissionCoordinator | undefined): void {
221
+ if (this.modelLoadAdmissionCoordinator === coordinator) return;
222
+ if (this.modelLoadAdmissionCoordinator) {
223
+ for (const [name, entry] of this.models) {
224
+ this.modelLoadAdmissionCoordinator.unbindRequestLoadAdmissions(name, entry.sessionRegistry);
225
+ }
226
+ }
227
+ this.modelLoadAdmissionCoordinator = coordinator;
228
+ if (coordinator) {
229
+ for (const [name, entry] of this.models) {
230
+ coordinator.bindRequestLoadAdmissions(name, entry.sessionRegistry);
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Register a model under a given name.
237
+ *
238
+ * If the name is already registered and the new model is a
239
+ * DIFFERENT instance, the old binding's refcount is decremented
240
+ * (and dropped if no other alias references it) before the new
241
+ * binding is taken. Re-registering with the SAME model instance
242
+ * leaves the binding unchanged.
243
+ *
244
+ * On first sight of a model object a fresh `SessionRegistry` is
245
+ * allocated. On alias the existing registry is reused so the
246
+ * single-warm invariant spans both names.
247
+ *
248
+ * Tombstone-inherit path: if the binding was previously torn down
249
+ * AND the hard-timeout breaker called
250
+ * `retireInstanceIdForForceRelease(model)` before teardown fired,
251
+ * the fresh binding inherits the retired instance id from
252
+ * `retiredInstanceIds` — so a late-landing persist's record stays
253
+ * chainable. A hot-swap (different model object) has no tombstone,
254
+ * so a fresh id is minted and the stale record fails
255
+ * `previous_response_id` with 400. The aliasing path naturally
256
+ * preserves the id because `instanceIds.has(model)` is already
257
+ * true.
258
+ */
259
+ register(name: string, model: ServableModel, opts?: RegisterOptions): void {
260
+ const samplingDefaults = opts?.samplingDefaults;
261
+ const maxOutputTokens = opts?.maxOutputTokens;
262
+ const existing = this.models.get(name);
263
+ if (existing && existing.model === model) {
264
+ // Same name + same model object: leave the binding and refcount
265
+ // alone. Refresh createdAt so `/v1/models` surfaces the most
266
+ // recent registration time. A fresh `samplingDefaults` from the
267
+ // re-registration is applied in place so the operator can tune
268
+ // per-model knobs without fully unregistering first.
269
+ existing.createdAt = Math.floor(Date.now() / 1000);
270
+ if (opts && 'samplingDefaults' in opts) {
271
+ existing.sessionRegistry.setSamplingDefaults(samplingDefaults);
272
+ }
273
+ if (opts && 'maxOutputTokens' in opts) {
274
+ existing.sessionRegistry.setMaxOutputTokens(maxOutputTokens);
275
+ }
276
+ this.modelLoadAdmissionCoordinator?.bindRequestLoadAdmissions(name, existing.sessionRegistry);
277
+ return;
278
+ }
279
+ if (existing) {
280
+ // Same name, different model: release the old model's refcount
281
+ // before installing the new binding.
282
+ this.modelLoadAdmissionCoordinator?.unbindRequestLoadAdmissions(name, existing.sessionRegistry);
283
+ this.dropNameReference(existing.model);
284
+ }
285
+
286
+ // Look up or allocate the shared binding. If it is still alive
287
+ // but flagged `pendingTeardown`, clear the flag and reuse it —
288
+ // the fresh registration revives the binding before teardown
289
+ // runs so the shared `SessionRegistry` / mutex chain stays
290
+ // identical and any new dispatch serializes behind the
291
+ // in-flight one.
292
+ let binding = this.sessionRegistriesByModel.get(model);
293
+ if (!binding) {
294
+ binding = {
295
+ registry: new SessionRegistry({
296
+ model,
297
+ maxQueueDepth: this.maxQueueDepth,
298
+ maxConcurrentDispatches: concurrentDispatchCapacity(model),
299
+ samplingDefaults,
300
+ maxOutputTokens,
301
+ }),
302
+ refCount: 0,
303
+ inFlight: 0,
304
+ pendingPersists: 0,
305
+ pendingTeardown: false,
306
+ };
307
+ this.sessionRegistriesByModel.set(model, binding);
308
+ } else {
309
+ if (binding.pendingTeardown) {
310
+ binding.pendingTeardown = false;
311
+ }
312
+ // Aliasing or reviving an existing binding: if this call passed
313
+ // `samplingDefaults` explicitly, overwrite the shared binding's
314
+ // defaults so the latest registration wins for every alias.
315
+ // Call sites that omit the field leave the existing defaults
316
+ // intact.
317
+ if (opts && 'samplingDefaults' in opts) {
318
+ binding.registry.setSamplingDefaults(samplingDefaults);
319
+ }
320
+ if (opts && 'maxOutputTokens' in opts) {
321
+ binding.registry.setMaxOutputTokens(maxOutputTokens);
322
+ }
323
+ }
324
+ binding.refCount += 1;
325
+ // Allocate a fresh monotonic instance id on first sight of this
326
+ // model object; reuse the existing id on every alias thereafter.
327
+ // Id lifetime mirrors the binding's — see `finalizeBindingTeardown`.
328
+ // If the binding was fully torn down but the hard-timeout breaker
329
+ // retired the previous id for the same model object, inherit it
330
+ // from the tombstone instead of minting fresh.
331
+ if (!this.instanceIds.has(model)) {
332
+ // Tombstone is refcounted; we do NOT decrement here — the
333
+ // still-pending persists own the outstanding count and balance
334
+ // it via `releaseTombstone` in their own `.finally(...)`.
335
+ const tombstone = this.retiredInstanceIds.get(model);
336
+ if (tombstone) {
337
+ this.instanceIds.set(model, tombstone.instanceId);
338
+ } else {
339
+ this.instanceIds.set(model, this.nextInstanceId);
340
+ this.nextInstanceId += 1;
341
+ }
342
+ }
343
+
344
+ this.models.set(name, {
345
+ id: name,
346
+ model,
347
+ createdAt: Math.floor(Date.now() / 1000),
348
+ sessionRegistry: binding.registry,
349
+ });
350
+ this.modelLoadAdmissionCoordinator?.bindRequestLoadAdmissions(name, binding.registry);
351
+ }
352
+
353
+ /**
354
+ * Unregister a model by name.
355
+ *
356
+ * Drops the name -> ModelEntry mapping and decrements the shared
357
+ * session-registry binding's refcount. When the refcount hits zero
358
+ * (no other alias references this model object) the binding — and
359
+ * the `SessionRegistry` it owns — is dropped entirely so cached
360
+ * sessions for the now-unreferenced model are released.
361
+ *
362
+ * @returns true if the model was removed.
363
+ */
364
+ unregister(name: string): boolean {
365
+ const entry = this.models.get(name);
366
+ if (!entry) return false;
367
+ this.modelLoadAdmissionCoordinator?.unbindRequestLoadAdmissions(name, entry.sessionRegistry);
368
+ this.models.delete(name);
369
+ this.dropNameReference(entry.model);
370
+ return true;
371
+ }
372
+
373
+ /**
374
+ * Decrement the refcount on a model binding; drop it at zero iff
375
+ * no dispatch holds a lease AND no post-commit persist is still
376
+ * retaining it. When either counter is non-zero the teardown is
377
+ * deferred via `pendingTeardown` so the `SessionRegistry` (and
378
+ * its `execLock` FIFO) stays alive until the last holder releases.
379
+ *
380
+ * A concurrent `register(sameModel)` between `dropNameReference()`
381
+ * and the final release clears `pendingTeardown` and reuses the
382
+ * still-live binding, preserving `modelInstanceId` so any row the
383
+ * pending persist is about to land still resolves to a live id
384
+ * when the next continuation arrives.
385
+ */
386
+ private dropNameReference(model: ServableModel): void {
387
+ const binding = this.sessionRegistriesByModel.get(model);
388
+ if (!binding) return;
389
+ binding.refCount -= 1;
390
+ if (binding.refCount <= 0) {
391
+ if (binding.inFlight > 0 || binding.pendingPersists > 0) {
392
+ // Defer until the last lease AND the last persist retention
393
+ // drop. The binding and its instance id stay in the maps so
394
+ // a same-object re-registration before finalisation can
395
+ // revive it in place.
396
+ binding.pendingTeardown = true;
397
+ return;
398
+ }
399
+ this.finalizeBindingTeardown(model);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Drop `model`'s binding and instance id from the registry.
405
+ * Shared teardown step; a subsequent re-registration usually mints
406
+ * a FRESH instance id — once the last alias, lease, and persist
407
+ * retention all release, any previously stored record referencing
408
+ * this id belongs to a logically dead binding and a continuation
409
+ * against it must fall through to the `currentInstanceId === undefined`
410
+ * rejection path so the stale chain cannot be replayed.
411
+ *
412
+ * Tombstone exception: if the hard-timeout breaker retired the
413
+ * previous id via `retireInstanceIdForForceRelease` before the
414
+ * forced release, a subsequent same-object `register()` inherits
415
+ * the retired id from `retiredInstanceIds` instead of minting
416
+ * fresh — this preserves chain continuity for late-landing
417
+ * persists that crossed the safety breaker.
418
+ */
419
+ private finalizeBindingTeardown(model: ServableModel): void {
420
+ const binding = this.sessionRegistriesByModel.get(model);
421
+ if (!binding) return;
422
+ binding.registry.clear();
423
+ // The binding maps are removed synchronously below, so no later request can
424
+ // discover this registry and call flushPendingDisposals() for it. Start the
425
+ // flush while the registry is still in hand: the promise retains it until
426
+ // every pending disposal has settled and every transient failure observed
427
+ // during this flush has received its one bounded retry.
428
+ void binding.registry.flushPendingDisposals().catch((error: unknown) => {
429
+ console.error('[server] failed to flush final chat-session owner disposals:', error);
430
+ });
431
+ this.sessionRegistriesByModel.delete(model);
432
+ this.instanceIds.delete(model);
433
+ }
434
+
435
+ /**
436
+ * Acquire a dispatch lease on the session registry bound to `name`.
437
+ * Returns the live `SessionRegistry` and the binding's instance id,
438
+ * or `undefined` if the name is not registered. Every successful
439
+ * acquisition MUST be balanced with exactly one
440
+ * `releaseDispatchLease(model)` call (typically via try/finally).
441
+ *
442
+ * The lease keeps the binding alive past a concurrent
443
+ * `unregister()` / `register(differentModel)` sequence: the
444
+ * `SessionRegistry` and its `execLock` FIFO remain valid while any
445
+ * lease is outstanding, so a newly registered same-model alias
446
+ * will rebind to the SAME registry and its `withExclusive` will
447
+ * serialize behind the in-flight dispatch.
448
+ *
449
+ * The returned `model` handle is what the caller passes to
450
+ * `releaseDispatchLease()` — the lease binds to the model OBJECT
451
+ * (not the friendly name) because the name can be hot-swapped
452
+ * while the lease is held.
453
+ */
454
+ acquireDispatchLease(
455
+ name: string,
456
+ ): { model: ServableModel; registry: SessionRegistry; instanceId: number } | undefined {
457
+ const entry = this.models.get(name);
458
+ if (!entry) return undefined;
459
+ const binding = this.sessionRegistriesByModel.get(entry.model);
460
+ if (!binding) return undefined;
461
+ const instanceId = this.instanceIds.get(entry.model);
462
+ if (instanceId === undefined) return undefined;
463
+ binding.inFlight += 1;
464
+ return { model: entry.model, registry: binding.registry, instanceId };
465
+ }
466
+
467
+ /**
468
+ * Release a dispatch lease previously obtained via
469
+ * `acquireDispatchLease()`. Decrements the binding's in-flight
470
+ * counter and, if the binding has been flagged for teardown (its
471
+ * refcount hit zero while the lease was held), drops it once the
472
+ * last lease releases. Safe to call exactly once per acquired
473
+ * lease; calling it on a model whose binding has already been
474
+ * fully torn down is a no-op.
475
+ */
476
+ releaseDispatchLease(model: ServableModel): void {
477
+ const binding = this.sessionRegistriesByModel.get(model);
478
+ if (!binding) return;
479
+ binding.inFlight -= 1;
480
+ if (binding.inFlight < 0) binding.inFlight = 0;
481
+ if (binding.pendingTeardown && binding.refCount <= 0 && binding.inFlight === 0 && binding.pendingPersists === 0) {
482
+ this.finalizeBindingTeardown(model);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Retain the binding for the duration of a post-commit persist.
488
+ *
489
+ * The responses endpoint starts `store.store(record)` synchronously
490
+ * inside `withExclusive` so the pending-writes tracker observes
491
+ * the in-flight write before the mutex releases, but does NOT
492
+ * await it on the critical path. The write still carries the
493
+ * binding's `modelInstanceId` (stamped into `configJson` by
494
+ * `buildResponseRecord`); without a retention, a same-model
495
+ * unregister + re-register completing while the write is in flight
496
+ * would delete the instance id and the re-registration would mint
497
+ * a fresh one, so the row — when it finally lands — would
498
+ * reference a dead id and the next continuation would be rejected
499
+ * with 400 instance-mismatch.
500
+ *
501
+ * The retention counter is CHECKED in every teardown gate
502
+ * (`dropNameReference`, `releaseDispatchLease`, `releaseBinding`).
503
+ * It is orthogonal to `inFlight` so the dispatch lease can release
504
+ * eagerly after `withExclusive` returns while the binding stays
505
+ * pinned long enough for the backgrounded `store.store(...)` to
506
+ * settle.
507
+ *
508
+ * Safe to call on a model whose binding has already been torn down
509
+ * (no-op). The matching `releaseBinding(model)` MUST still run in
510
+ * the persist's `.finally(...)` so the counter stays balanced.
511
+ */
512
+ retainBinding(model: ServableModel): void {
513
+ const binding = this.sessionRegistriesByModel.get(model);
514
+ if (!binding) return;
515
+ binding.pendingPersists += 1;
516
+ }
517
+
518
+ /**
519
+ * Balance a prior `retainBinding()` call. Decrements the persist
520
+ * retention counter and, if the binding has been flagged for
521
+ * teardown (refcount hit zero while the retention was held AND
522
+ * every dispatch lease has already released), drops it once the
523
+ * last retention releases. Safe to call exactly once per retain;
524
+ * calling it on a model whose binding has already been fully torn
525
+ * down is a no-op.
526
+ */
527
+ releaseBinding(model: ServableModel): void {
528
+ const binding = this.sessionRegistriesByModel.get(model);
529
+ if (!binding) return;
530
+ binding.pendingPersists -= 1;
531
+ if (binding.pendingPersists < 0) binding.pendingPersists = 0;
532
+ if (binding.pendingTeardown && binding.refCount <= 0 && binding.inFlight === 0 && binding.pendingPersists === 0) {
533
+ this.finalizeBindingTeardown(model);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Tombstone installer, invoked exclusively by the responses
539
+ * endpoint's hard-timeout breaker (see
540
+ * `getPostCommitPersistHardTimeoutMs` in `endpoints/responses.ts`)
541
+ * when it force-releases the `retainBinding` on a wedged persist.
542
+ * Must be called BEFORE the idempotent
543
+ * `persistRetainBox.release?.()` so `instanceIds.get(model)`
544
+ * still returns the live id that the already-stamped record
545
+ * carries.
546
+ *
547
+ * Returns the retired id so the caller can capture it and scope
548
+ * the tombstone's lifetime to the specific wedged persist via
549
+ * `releaseTombstone(model)` inside that persist's `.finally(...)`.
550
+ * Returns `undefined` when the model has no current instance id
551
+ * assignment (caller raced the natural teardown path).
552
+ *
553
+ * Refcounted: each call increments a shared
554
+ * `{ instanceId, outstandingCount }` entry per model. Overlapping
555
+ * breakers share one slot (they all target the same numeric id
556
+ * because `register()` inherits the retired id whenever the
557
+ * tombstone is present), so memory stays O(1) per model.
558
+ */
559
+ retireInstanceIdForForceRelease(model: ServableModel): { instanceId: number } | undefined {
560
+ const id = this.instanceIds.get(model);
561
+ if (id === undefined) return undefined;
562
+ const existing = this.retiredInstanceIds.get(model);
563
+ if (existing) {
564
+ existing.outstandingCount += 1;
565
+ return { instanceId: existing.instanceId };
566
+ }
567
+ this.retiredInstanceIds.set(model, { instanceId: id, outstandingCount: 1 });
568
+ return { instanceId: id };
569
+ }
570
+
571
+ /**
572
+ * Tombstone cleanup. Called from the post-commit persist's
573
+ * `.finally(...)` to balance exactly one prior
574
+ * `retireInstanceIdForForceRelease(model)` call. Decrements the
575
+ * shared refcount and drops the entry at zero so the next natural
576
+ * teardown mints a fresh id.
577
+ *
578
+ * Safe to call on a model whose tombstone has already been drained
579
+ * (no-op). The counter is clamped non-negative so spurious
580
+ * releases cannot underflow and re-enable inheritance.
581
+ */
582
+ releaseTombstone(model: ServableModel): void {
583
+ const entry = this.retiredInstanceIds.get(model);
584
+ if (!entry) return;
585
+ entry.outstandingCount -= 1;
586
+ if (entry.outstandingCount <= 0) {
587
+ this.retiredInstanceIds.delete(model);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Retrieve a model instance by name.
593
+ */
594
+ get(name: string): ServableModel | undefined {
595
+ return this.models.get(name)?.model;
596
+ }
597
+
598
+ /**
599
+ * Retrieve the monotonic instance id for the model currently bound
600
+ * to `name`, or `undefined` if the name isn't registered.
601
+ *
602
+ * Two names that alias the same model object return the SAME id
603
+ * (they share a binding), and a name that has been hot-swapped to
604
+ * a different model object returns a DIFFERENT id than before the
605
+ * swap (the prior binding's id was dropped by `dropNameReference`
606
+ * and a fresh id was minted for the new model on re-registration).
607
+ *
608
+ * The responses endpoint uses this to key the
609
+ * `previous_response_id` cross-chain guard on instance identity
610
+ * instead of friendly name, so hot swaps are caught and safe
611
+ * aliases are not spuriously rejected.
612
+ */
613
+ getInstanceId(name: string): number | undefined {
614
+ const entry = this.models.get(name);
615
+ if (!entry) return undefined;
616
+ return this.instanceIds.get(entry.model);
617
+ }
618
+
619
+ /**
620
+ * Retrieve the session registry for a given model name, or
621
+ * `undefined` if the name is not registered.
622
+ *
623
+ * Every name that points at the same model instance returns the
624
+ * SAME `SessionRegistry` object. Two aliases `a` and `b` of one
625
+ * model therefore satisfy
626
+ * `registry.getSessionRegistry('a') === registry.getSessionRegistry('b')`,
627
+ * which is what the single-warm invariant requires: any turn
628
+ * through either alias advances the same cache's state, so a later
629
+ * lookup via either alias sees the current warm wrapper (if
630
+ * freshly adopted) or misses and cold-replays (if it was leased
631
+ * out by the other alias) — never a stale wrapper pointing at
632
+ * stomped native state.
633
+ */
634
+ getSessionRegistry(name: string): SessionRegistry | undefined {
635
+ return this.models.get(name)?.sessionRegistry;
636
+ }
637
+
638
+ /**
639
+ * Iterate every DISTINCT session registry currently in use.
640
+ *
641
+ * Two aliases of the same model share one `SessionRegistry`, so
642
+ * naively walking every `ModelEntry` would yield duplicates. We
643
+ * walk the identity-keyed bindings instead so each registry
644
+ * appears exactly once, which is what the periodic `sweep()`
645
+ * scheduler in `server.ts` needs to avoid redundantly sweeping
646
+ * the same cache multiple times per tick.
647
+ */
648
+ listSessionRegistries(): SessionRegistry[] {
649
+ const out: SessionRegistry[] = [];
650
+ for (const binding of this.sessionRegistriesByModel.values()) {
651
+ out.push(binding.registry);
652
+ }
653
+ return out;
654
+ }
655
+
656
+ /**
657
+ * List all registered models in the OpenAI /v1/models format.
658
+ */
659
+ list(): { id: string; object: string; created: number; owned_by: string }[] {
660
+ const result: {
661
+ id: string;
662
+ object: string;
663
+ created: number;
664
+ owned_by: string;
665
+ }[] = [];
666
+ for (const entry of this.models.values()) {
667
+ result.push({
668
+ id: entry.id,
669
+ object: 'model',
670
+ created: entry.createdAt,
671
+ owned_by: 'mlx-node',
672
+ });
673
+ }
674
+ return result;
675
+ }
676
+
677
+ /**
678
+ * Check whether a model supports streaming.
679
+ *
680
+ * Every `SessionCapableModel` structurally exposes
681
+ * `chatStreamSessionStart`, so this is universally `true` for any
682
+ * properly-typed model registered through the session-capable
683
+ * interface. Kept as a belt-and-suspenders duck-type so a partially
684
+ * stubbed test double (pre-migration or intentionally non-streaming)
685
+ * can still opt out by omitting the method.
686
+ */
687
+ hasStreamSupport(model: ServableModel): boolean {
688
+ const fn = (model as unknown as Record<string, unknown>)['chatStreamSessionStart'];
689
+ return typeof fn === 'function';
690
+ }
691
+ }