@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.
- package/dist/endpoints/messages.d.ts +13 -0
- package/dist/endpoints/messages.d.ts.map +1 -0
- package/dist/endpoints/messages.js +511 -0
- package/dist/endpoints/models.d.ts +5 -0
- package/dist/endpoints/models.d.ts.map +1 -0
- package/dist/endpoints/models.js +10 -0
- package/dist/endpoints/responses.d.ts +79 -0
- package/dist/endpoints/responses.d.ts.map +1 -0
- package/dist/endpoints/responses.js +2816 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +84 -0
- package/dist/handler.d.ts +18 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +35 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/mappers/anthropic-request.d.ts +9 -0
- package/dist/mappers/anthropic-request.d.ts.map +1 -0
- package/dist/mappers/anthropic-request.js +241 -0
- package/dist/mappers/anthropic-response.d.ts +14 -0
- package/dist/mappers/anthropic-response.d.ts.map +1 -0
- package/dist/mappers/anthropic-response.js +112 -0
- package/dist/mappers/request.d.ts +18 -0
- package/dist/mappers/request.d.ts.map +1 -0
- package/dist/mappers/request.js +206 -0
- package/dist/mappers/response.d.ts +13 -0
- package/dist/mappers/response.d.ts.map +1 -0
- package/dist/mappers/response.js +116 -0
- package/dist/pending-writes.d.ts +337 -0
- package/dist/pending-writes.d.ts.map +1 -0
- package/dist/pending-writes.js +468 -0
- package/dist/registry.d.ts +363 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +497 -0
- package/dist/router.d.ts +6 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +78 -0
- package/dist/server.d.ts +80 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +158 -0
- package/dist/session-registry.d.ts +297 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +403 -0
- package/dist/streaming.d.ts +7 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +16 -0
- package/dist/tool-call-buffer.d.ts +26 -0
- package/dist/tool-call-buffer.d.ts.map +1 -0
- package/dist/tool-call-buffer.js +51 -0
- package/dist/transport-visibility.d.ts +56 -0
- package/dist/transport-visibility.d.ts.map +1 -0
- package/dist/transport-visibility.js +161 -0
- package/dist/types-anthropic.d.ts +144 -0
- package/dist/types-anthropic.d.ts.map +1 -0
- package/dist/types-anthropic.js +2 -0
- package/dist/types.d.ts +220 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +36 -0
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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"}
|