@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,13 @@
1
+ /**
2
+ * POST /v1/messages — stateless Anthropic Messages API.
3
+ *
4
+ * Every request carries the full conversation in `req.messages`. We allocate
5
+ * a fresh `ChatSession` per request via `SessionRegistry.getOrCreate(null)`,
6
+ * prime with the mapped history, and run `startFromHistory[Stream]`. No
7
+ * adopt/drop: the session's lifetime is this single call.
8
+ */
9
+ import type { IncomingMessage, ServerResponse } from 'node:http';
10
+ import type { ModelRegistry } from '../registry.js';
11
+ import type { AnthropicMessagesRequest } from '../types-anthropic.js';
12
+ export declare function handleCreateMessage(res: ServerResponse, body: AnthropicMessagesRequest, registry: ModelRegistry, httpReq?: IncomingMessage): Promise<void>;
13
+ //# sourceMappingURL=messages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/endpoints/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAuBjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AA0XtE,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,wBAAwB,EAC9B,QAAQ,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,IAAI,CAAC,CA4Of"}
@@ -0,0 +1,511 @@
1
+ /**
2
+ * POST /v1/messages — stateless Anthropic Messages API.
3
+ *
4
+ * Every request carries the full conversation in `req.messages`. We allocate
5
+ * a fresh `ChatSession` per request via `SessionRegistry.getOrCreate(null)`,
6
+ * prime with the mapped history, and run `startFromHistory[Stream]`. No
7
+ * adopt/drop: the session's lifetime is this single call.
8
+ */
9
+ import { sendAnthropicBadRequest, sendAnthropicInternalError, sendAnthropicNotFound, sendAnthropicRateLimit, } from '../errors.js';
10
+ import { mapAnthropicRequest } from '../mappers/anthropic-request.js';
11
+ import { buildAnthropicResponse, buildContentBlockDelta, buildContentBlockStart, buildContentBlockStop, buildMessageDelta, buildMessageStartEvent, buildMessageStop, mapStopReason, } from '../mappers/anthropic-response.js';
12
+ import { genId } from '../mappers/response.js';
13
+ import { QueueFullError } from '../session-registry.js';
14
+ import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
15
+ import { ToolCallTagBuffer } from '../tool-call-buffer.js';
16
+ import { createVisibility, endJson, flushTerminalSSE, markSSEMode, writeFallbackErrorSSE, } from '../transport-visibility.js';
17
+ import { validateAndCanonicalizeHistoryToolOrder } from './responses.js';
18
+ // Non-streaming path
19
+ async function handleNonStreaming(res, result, body, visibility) {
20
+ const messageId = genId('msg_');
21
+ const response = buildAnthropicResponse(result, body, messageId);
22
+ // Native `chatSession*` has no AbortSignal surface yet, so a client that
23
+ // disconnects mid-decode still burns every remaining token under the
24
+ // per-model mutex. Disconnect handling is delegated to `endJson`'s
25
+ // pre-entry destroyed check, which rejects synchronously after `responseMode`
26
+ // has been committed to 'json' — the outer catch then destroys the socket.
27
+ await endJson(res, JSON.stringify(response), visibility);
28
+ }
29
+ // Streaming path
30
+ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpReq, visibility) {
31
+ const messageId = genId('msg_');
32
+ beginSSE(res);
33
+ // Commit SSE wire format now so any throw before the terminal event routes
34
+ // to the streaming error epilogue instead of corrupting the JSON path.
35
+ markSSEMode(visibility);
36
+ writeSSEEvent(res, 'message_start', buildMessageStartEvent(body, messageId, 0));
37
+ let contentBlockIndex = 0;
38
+ let hasEmittedThinking = false;
39
+ let hasEmittedText = false;
40
+ let emittedTextLength = 0;
41
+ const tagBuffer = new ToolCallTagBuffer();
42
+ // Terminal emission is deferred until after the loop drains so `wasCommitted()`
43
+ // reads an authoritative `session.turns`. On a committed done chunk we emit
44
+ // `message_delta` + `message_stop`; on an uncommitted terminal (finishReason=error,
45
+ // mid-decode throw, client abort, iterator exhaustion) we emit a single streaming
46
+ // `error` event and withhold `message_stop`.
47
+ let sawDone = false;
48
+ let terminalStopReason = null;
49
+ let terminalNumTokens = 0;
50
+ let terminalPromptTokens;
51
+ let terminalErrorMessage = null;
52
+ // `thrownError` sticks on a generator throw; `clientAborted` sticks on
53
+ // HTTP `close`/`error` on req, res, or res.socket. Either one routes the
54
+ // post-loop block to the failure epilogue. Native decode has no
55
+ // AbortSignal yet, so on a client disconnect we can only stop consuming
56
+ // deltas — the native decode still runs to completion under the mutex.
57
+ let thrownError = null;
58
+ let clientAborted = false;
59
+ const onClientClose = () => {
60
+ clientAborted = true;
61
+ };
62
+ const onClientError = (_err) => {
63
+ clientAborted = true;
64
+ };
65
+ const onResClose = () => {
66
+ clientAborted = true;
67
+ };
68
+ const onResError = (_err) => {
69
+ clientAborted = true;
70
+ };
71
+ const resSocketForAbort = res.socket;
72
+ if (httpReq) {
73
+ httpReq.once('close', onClientClose);
74
+ httpReq.once('error', onClientError);
75
+ }
76
+ res.once('close', onResClose);
77
+ res.once('error', onResError);
78
+ if (resSocketForAbort != null) {
79
+ resSocketForAbort.once('close', onResClose);
80
+ }
81
+ try {
82
+ for await (const event of chatStream) {
83
+ if (clientAborted)
84
+ break;
85
+ if (event.done) {
86
+ sawDone = true;
87
+ // An error terminal must NOT flush content blocks — doing so would race
88
+ // with the post-loop close and advertise a clean fan-out that the
89
+ // session rolled back.
90
+ if (event.finishReason === 'error') {
91
+ terminalErrorMessage = 'model reported finishReason=error';
92
+ break;
93
+ }
94
+ const remainingText = tagBuffer.flush();
95
+ if (!tagBuffer.suppressed && remainingText) {
96
+ if (!hasEmittedText) {
97
+ if (hasEmittedThinking) {
98
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
99
+ }
100
+ hasEmittedText = true;
101
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
102
+ }
103
+ emittedTextLength += remainingText.length;
104
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: remainingText }));
105
+ }
106
+ if (hasEmittedThinking && !hasEmittedText) {
107
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
108
+ }
109
+ const finalText = event.text;
110
+ const okToolCalls = event.toolCalls.filter((t) => t.status === 'ok');
111
+ const hasToolCalls = okToolCalls.length > 0;
112
+ // Recovery: suppression triggered but no tool calls parsed — emit final text as a text block.
113
+ if (tagBuffer.suppressed && !hasToolCalls && finalText && !hasEmittedText) {
114
+ // Thinking block (if any) was already closed above.
115
+ hasEmittedText = true;
116
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
117
+ emittedTextLength += finalText.length;
118
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: finalText }));
119
+ }
120
+ else if (tagBuffer.suppressed && !hasToolCalls && finalText && hasEmittedText) {
121
+ // Recovery: streaming text was cut off by a false-alarm `<tool_call>` tag. Emit the unsent suffix.
122
+ const unsent = finalText.slice(emittedTextLength);
123
+ if (unsent) {
124
+ emittedTextLength += unsent.length;
125
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: unsent }));
126
+ }
127
+ }
128
+ // Emit any unsent suffix when final text is longer than what was streamed.
129
+ if (hasEmittedText && finalText && finalText.length > emittedTextLength) {
130
+ const unsent = finalText.slice(emittedTextLength);
131
+ emittedTextLength += unsent.length;
132
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: unsent }));
133
+ }
134
+ if (hasEmittedText) {
135
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
136
+ contentBlockIndex++;
137
+ }
138
+ else if (!finalText && hasToolCalls) {
139
+ // Pure tool-call turn — no text block.
140
+ }
141
+ else if (finalText) {
142
+ // All text arrived in the final event; emit it as a single block.
143
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
144
+ emittedTextLength += finalText.length;
145
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: finalText }));
146
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
147
+ contentBlockIndex++;
148
+ }
149
+ for (const tc of okToolCalls) {
150
+ const toolId = tc.id ?? genId('toolu_');
151
+ const parsedInput = typeof tc.arguments === 'string'
152
+ ? JSON.parse(tc.arguments)
153
+ : tc.arguments;
154
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'tool_use', id: toolId, name: tc.name, input: {} }));
155
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
156
+ type: 'input_json_delta',
157
+ partial_json: JSON.stringify(parsedInput),
158
+ }));
159
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
160
+ contentBlockIndex++;
161
+ }
162
+ // Capture terminal state and break — actual `message_delta` / `message_stop` /
163
+ // `error` emission is deferred until after the loop so `wasCommitted()` reads
164
+ // an authoritative `session.turns` (the producer's finally runs on break).
165
+ terminalStopReason = mapStopReason(event.finishReason, hasToolCalls);
166
+ terminalNumTokens = event.numTokens;
167
+ terminalPromptTokens = event.promptTokens;
168
+ break;
169
+ }
170
+ // Delta event
171
+ if (event.isReasoning) {
172
+ const deltaText = event.text.replace(/<\/think>/g, '');
173
+ if (!deltaText)
174
+ continue;
175
+ if (!hasEmittedThinking) {
176
+ hasEmittedThinking = true;
177
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'thinking', thinking: '' }));
178
+ contentBlockIndex++;
179
+ }
180
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex - 1, { type: 'thinking_delta', thinking: deltaText }));
181
+ }
182
+ else {
183
+ // Text delta with `<tool_call>` buffering.
184
+ const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
185
+ if (tagFound) {
186
+ if (cleanPrefix.trim()) {
187
+ if (!hasEmittedText) {
188
+ if (hasEmittedThinking) {
189
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
190
+ }
191
+ hasEmittedText = true;
192
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
193
+ }
194
+ emittedTextLength += cleanPrefix.length;
195
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: cleanPrefix }));
196
+ }
197
+ }
198
+ else if (safeText) {
199
+ if (!hasEmittedText) {
200
+ if (hasEmittedThinking) {
201
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
202
+ }
203
+ hasEmittedText = true;
204
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
205
+ }
206
+ emittedTextLength += safeText.length;
207
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: safeText }));
208
+ }
209
+ }
210
+ }
211
+ }
212
+ catch (err) {
213
+ // Capture into a sticky flag so the post-loop block routes through the failure
214
+ // epilogue (single streaming `error` event, no `message_stop`).
215
+ thrownError = err instanceof Error ? err : new Error(String(err));
216
+ }
217
+ finally {
218
+ if (httpReq) {
219
+ httpReq.off('close', onClientClose);
220
+ httpReq.off('error', onClientError);
221
+ }
222
+ res.off('close', onResClose);
223
+ res.off('error', onResError);
224
+ if (resSocketForAbort != null) {
225
+ resSocketForAbort.off('close', onResClose);
226
+ }
227
+ }
228
+ // Success requires ALL of: sawDone, wasCommitted, no thrown error, no client abort.
229
+ // Every failure path emits a streaming `error` and withholds `message_stop`.
230
+ const committed = wasCommitted();
231
+ const successful = sawDone && committed && thrownError == null && !clientAborted;
232
+ if (successful) {
233
+ const stopReason = terminalStopReason ?? 'end_turn';
234
+ writeSSEEvent(res, 'message_delta', buildMessageDelta(stopReason, terminalNumTokens, terminalPromptTokens));
235
+ await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
236
+ }
237
+ else {
238
+ // Close any dangling content block so the error frame lands at a clean state,
239
+ // then emit the streaming error. Never emit `message_stop` here — pairing it
240
+ // with an error would tell the client the turn completed cleanly.
241
+ if (hasEmittedThinking && !hasEmittedText) {
242
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
243
+ }
244
+ else if (hasEmittedText) {
245
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
246
+ }
247
+ let message;
248
+ if (thrownError != null) {
249
+ message = thrownError.message;
250
+ }
251
+ else if (clientAborted) {
252
+ message = 'client disconnected before the stream completed';
253
+ }
254
+ else if (terminalErrorMessage != null) {
255
+ message = terminalErrorMessage;
256
+ }
257
+ else if (sawDone) {
258
+ message = 'model refused to commit the turn';
259
+ }
260
+ else {
261
+ message = 'stream ended without a done event';
262
+ }
263
+ // The streaming `error` event is the Anthropic terminal on the failure path.
264
+ await flushTerminalSSE(res, 'error', { type: 'error', error: { type: 'api_error', message } }, visibility);
265
+ }
266
+ endSSE(res);
267
+ }
268
+ // Session routing
269
+ /** Prime a fresh session with the full history and run a single turn. */
270
+ async function runSessionNonStreaming(session, messages, config) {
271
+ session.primeHistory(messages);
272
+ return await session.startFromHistory(config);
273
+ }
274
+ function runSessionStreaming(session, messages, config, signal) {
275
+ session.primeHistory(messages);
276
+ const initialTurns = session.turns;
277
+ return {
278
+ stream: session.startFromHistoryStream(config, signal),
279
+ wasCommitted: () => session.turns > initialTurns,
280
+ };
281
+ }
282
+ // Public handler
283
+ export async function handleCreateMessage(res, body, registry, httpReq) {
284
+ if (body == null || typeof body !== 'object') {
285
+ sendAnthropicBadRequest(res, 'Request body must be a JSON object');
286
+ return;
287
+ }
288
+ if (!body.model) {
289
+ sendAnthropicBadRequest(res, 'Missing required field: model');
290
+ return;
291
+ }
292
+ if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
293
+ sendAnthropicBadRequest(res, 'Missing required field: messages');
294
+ return;
295
+ }
296
+ if (body.max_tokens == null || !Number.isInteger(body.max_tokens) || body.max_tokens <= 0) {
297
+ sendAnthropicBadRequest(res, 'Missing required field: max_tokens');
298
+ return;
299
+ }
300
+ for (const msg of body.messages) {
301
+ if (msg == null || typeof msg !== 'object') {
302
+ sendAnthropicBadRequest(res, 'Each message must be a non-null object');
303
+ return;
304
+ }
305
+ }
306
+ const model = registry.get(body.model);
307
+ if (!model) {
308
+ sendAnthropicNotFound(res, `Model "${body.model}" not found`);
309
+ return;
310
+ }
311
+ // The lease keeps the binding's FIFO `execLock` chain alive across every
312
+ // await — a concurrent `unregister()` + `register(sameModel)` would otherwise
313
+ // tear down the old `SessionRegistry` and race two independent mutex chains
314
+ // against one shared native model. Must be released in the `finally` below.
315
+ const lease = registry.acquireDispatchLease(body.model);
316
+ if (!lease) {
317
+ sendAnthropicInternalError(res, 'session registry missing for registered model');
318
+ return;
319
+ }
320
+ const leaseModel = lease.model;
321
+ // AbortController wired to disconnect events. Declared at function scope
322
+ // so the outer `finally` can detach listeners on early returns; the
323
+ // `abortListenersAttached` flag gates the detach so pre-validation exits
324
+ // skip it safely.
325
+ const abortController = new AbortController();
326
+ const abortSocket = res.socket;
327
+ const onAbortClose = () => {
328
+ abortController.abort();
329
+ };
330
+ const onAbortError = (_err) => {
331
+ abortController.abort();
332
+ };
333
+ let abortListenersAttached = false;
334
+ try {
335
+ const sessionReg = lease.registry;
336
+ // Snapshot the monotonic instance id so the in-mutex re-read can detect a
337
+ // hot-swap that lands between lease acquisition and mutex entry. Unlike
338
+ // `/v1/responses`, the Anthropic handler has no stored-identity check
339
+ // downstream to catch the race later.
340
+ const preLockInstanceId = lease.instanceId;
341
+ let messages;
342
+ let config;
343
+ try {
344
+ ({ messages, config } = mapAnthropicRequest(body));
345
+ }
346
+ catch (err) {
347
+ sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
348
+ return;
349
+ }
350
+ // Canonicalize every assistant fan-out's trailing tool block against its
351
+ // declared sibling order. Several native session backends pair tool results
352
+ // to fan-out calls POSITIONALLY (not by id), so caller-reversed sibling
353
+ // results would silently bind to the wrong call. `'anthropic'` selects
354
+ // error-message vocabulary (`tool_result` / `tool_use_id`).
355
+ const historyError = validateAndCanonicalizeHistoryToolOrder(messages, 'anthropic');
356
+ if (historyError !== null) {
357
+ sendAnthropicBadRequest(res, historyError);
358
+ return;
359
+ }
360
+ // The system prompt is baked into `messages` and replayed via `startFromHistory`,
361
+ // so it cannot leak across requests. We still pass a canonicalized form to
362
+ // `getOrCreate` to keep the registry API uniform with `/v1/responses`. Arrays
363
+ // are JSON-stringified; plain strings pass through.
364
+ let requestedSystem;
365
+ if (typeof body.system === 'string') {
366
+ requestedSystem = body.system;
367
+ }
368
+ else if (body.system != null) {
369
+ requestedSystem = JSON.stringify(body.system);
370
+ }
371
+ else {
372
+ requestedSystem = null;
373
+ }
374
+ // Per-model execution mutex. Every dispatch through `/v1/messages` serializes
375
+ // with every dispatch through `/v1/responses` for the same model binding.
376
+ // The native `SessionCapableModel` is a single mutable resource (shared
377
+ // `cached_token_history` / `caches`), so two concurrent `primeHistory` +
378
+ // `startFromHistory` would clobber each other's KV state.
379
+ //
380
+ // Arm the AbortController now — past all validation gates, so the
381
+ // matching detach in the outer `finally` is guarded by
382
+ // `abortListenersAttached`. Streaming wrappers in `@mlx-node/lm` plumb
383
+ // this signal through `_runChatStream` to cancel the native
384
+ // `ChatStreamHandle` and unblock the pending `waitForItem()` on
385
+ // disconnect.
386
+ res.once('close', onAbortClose);
387
+ res.once('error', onAbortError);
388
+ if (abortSocket != null) {
389
+ abortSocket.once('close', onAbortClose);
390
+ }
391
+ if (httpReq) {
392
+ httpReq.once('close', onAbortClose);
393
+ httpReq.once('error', onAbortError);
394
+ }
395
+ abortListenersAttached = true;
396
+ const streamSignal = abortController.signal;
397
+ try {
398
+ await sessionReg.withExclusive(async () => {
399
+ // Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
400
+ // `withExclusive`, so a concurrent re-register of the same friendly name
401
+ // could silently dispatch this request through a stale model. Any drift
402
+ // from the pre-lock snapshot is fatal.
403
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
404
+ const lockedInstanceId = registry.getInstanceId(body.model);
405
+ if (lockedSessionReg === undefined ||
406
+ lockedInstanceId === undefined ||
407
+ lockedSessionReg !== sessionReg ||
408
+ lockedInstanceId !== preLockInstanceId) {
409
+ sendAnthropicBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
410
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
411
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
412
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
413
+ `service this request through a stale model object — a silent cross-model handoff. Retry the ` +
414
+ `request — if the swap was intentional, the new binding will service the retry cleanly.`);
415
+ return;
416
+ }
417
+ const session = sessionReg.getOrCreate(null, requestedSystem).session;
418
+ // `X-Session-Cache` observability header: `/v1/messages` is
419
+ // stateless — every request allocates a fresh `ChatSession` via
420
+ // `getOrCreate(null, …)` — so the status is always `fresh`. Emit
421
+ // it anyway to keep the header contract uniform with
422
+ // `/v1/responses`, and set it before any `writeHead` / SSE
423
+ // `beginSSE` so it lands on both JSON and SSE responses.
424
+ res.setHeader('X-Session-Cache', 'fresh');
425
+ // Outer catch branches on `responseMode` (not `res.headersSent`, which
426
+ // flips in `writeHead` before the body lands) so a crash after
427
+ // `writeHead(application/json)` cannot leak SSE frames into a JSON body.
428
+ const visibility = createVisibility();
429
+ try {
430
+ if (body.stream === true) {
431
+ const outcome = runSessionStreaming(session, messages, config, streamSignal);
432
+ await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility);
433
+ }
434
+ else {
435
+ // Native `chatSessionStart` has no AbortSignal yet — disconnect handling
436
+ // lives inside `handleNonStreaming` / `endJson`.
437
+ const result = await runSessionNonStreaming(session, messages, config);
438
+ await handleNonStreaming(res, result, body, visibility);
439
+ }
440
+ }
441
+ catch (err) {
442
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
443
+ if (visibility.responseMode === null) {
444
+ sendAnthropicInternalError(res, message);
445
+ }
446
+ else if (visibility.responseMode === 'json') {
447
+ // Already committed to JSON — destroy the socket rather than corrupt the body.
448
+ try {
449
+ res.destroy(err instanceof Error ? err : new Error(message));
450
+ }
451
+ catch {
452
+ // Socket may already be gone.
453
+ }
454
+ }
455
+ else {
456
+ // SSE: best-effort streaming `error`, but only if no terminal landed
457
+ // (a double terminal would confuse the client state machine).
458
+ if (!visibility.terminalEmitted) {
459
+ writeFallbackErrorSSE(res, 'error', { error: { type: 'api_error', message } });
460
+ }
461
+ try {
462
+ endSSE(res);
463
+ }
464
+ catch {
465
+ // Already closed.
466
+ }
467
+ }
468
+ }
469
+ });
470
+ }
471
+ catch (err) {
472
+ // Admission-control rejection from the per-model queue cap
473
+ // (`SessionRegistry.withExclusive` threw before chaining into
474
+ // the FIFO). Emit Anthropic-shape HTTP 429 so clients back off
475
+ // instead of silently piling up more waiters. The outer
476
+ // `finally` below still detaches abort listeners and releases
477
+ // the dispatch lease, so no per-request resources are leaked.
478
+ //
479
+ // Any other error continues to propagate so an abnormal failure
480
+ // still routes through the handler's existing error paths.
481
+ if (err instanceof QueueFullError) {
482
+ if (!res.headersSent) {
483
+ sendAnthropicRateLimit(res, `Model queue full: ${err.queuedCount} waiting (limit ${err.limit}). Retry after 1s.`);
484
+ }
485
+ }
486
+ else {
487
+ throw err;
488
+ }
489
+ }
490
+ }
491
+ finally {
492
+ // Drop disconnect listeners so they don't pin the request past handler
493
+ // return. Only detach if we actually attached (gated by the flag).
494
+ if (abortListenersAttached) {
495
+ res.removeListener('close', onAbortClose);
496
+ res.removeListener('error', onAbortError);
497
+ if (abortSocket != null) {
498
+ abortSocket.removeListener('close', onAbortClose);
499
+ }
500
+ if (httpReq) {
501
+ httpReq.removeListener('close', onAbortClose);
502
+ httpReq.removeListener('error', onAbortError);
503
+ }
504
+ }
505
+ // Release against the ORIGINAL lease model — re-reading `body.model`
506
+ // would resolve to a possibly hot-swapped binding. A concurrent
507
+ // `unregister()` held against this lease finalises its teardown here
508
+ // when the in-flight counter drops to zero.
509
+ registry.releaseDispatchLease(leaseModel);
510
+ }
511
+ }
@@ -0,0 +1,5 @@
1
+ /** GET /v1/models endpoint. */
2
+ import type { ServerResponse } from 'node:http';
3
+ import type { ModelRegistry } from '../registry.js';
4
+ export declare function handleListModels(res: ServerResponse, registry: ModelRegistry): void;
5
+ //# sourceMappingURL=models.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/endpoints/models.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,GAAG,IAAI,CAQnF"}
@@ -0,0 +1,10 @@
1
+ /** GET /v1/models endpoint. */
2
+ export function handleListModels(res, registry) {
3
+ const models = registry.list();
4
+ const body = {
5
+ object: 'list',
6
+ data: models,
7
+ };
8
+ res.writeHead(200, { 'Content-Type': 'application/json' });
9
+ res.end(JSON.stringify(body));
10
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * POST /v1/responses — OpenAI Responses API, streaming (SSE) and non-streaming (JSON).
3
+ *
4
+ * Dispatches to loaded models via `ModelRegistry`. Inference goes through a per-model
5
+ * `ChatSession` looked up by `previous_response_id` in the model's `SessionRegistry`: a
6
+ * hit reuses the live KV cache (`send` / `sendStream` / `sendToolResult`); a miss
7
+ * reconstructs the full conversation from `ResponseStore` and cold-replays via
8
+ * `primeHistory` + `startFromHistory[Stream]`.
9
+ */
10
+ import type { IncomingMessage, ServerResponse } from 'node:http';
11
+ import type { ChatMessage, ResponseStore } from '@mlx-node/core';
12
+ import type { ModelRegistry } from '../registry.js';
13
+ import type { ResponsesAPIRequest } from '../types.js';
14
+ /**
15
+ * Value of the `X-Session-Cache` response header emitted on every
16
+ * `/v1/responses` and `/v1/messages` response. Advertises whether the
17
+ * request warm-hit the per-model `SessionRegistry` (`hit`), missed and
18
+ * cold-replayed from the stored chain (`cold_replay`), or started a
19
+ * fresh session without a `previous_response_id` (`fresh`). The literal
20
+ * string values are load-bearing — clients and operator tooling pin on
21
+ * them.
22
+ */
23
+ export type SessionCacheStatus = 'hit' | 'cold_replay' | 'fresh';
24
+ /**
25
+ * Hard timeout (ms) for the off-lock post-commit persist — the
26
+ * second-stage breaker that force-releases the `retainBinding` paired
27
+ * with `initiatePersist` when the write is truly wedged (never settles).
28
+ *
29
+ * The soft persist timeout above only detaches the handler; the retain
30
+ * stays pinned so a slow-but-eventual write still lands against the
31
+ * live `modelInstanceId`. This hard breaker bounds the leak for a
32
+ * genuinely wedged promise at this value instead of process lifetime.
33
+ * On fire, it also retires the instance id via a refcounted tombstone
34
+ * so a same-object re-registration inherits the id and the late write
35
+ * remains chainable — a true hot-swap to a different object still
36
+ * mints a fresh id and correctly fails stale chains with 400.
37
+ *
38
+ * Default 60000ms — well past the soft timeout so slow-but-eventual
39
+ * writes are unaffected. Override via `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`:
40
+ * empty/whitespace-only falls back to default (so a config-templating
41
+ * typo cannot silently disable the breaker); `'0'` explicitly disables;
42
+ * non-numeric garbage falls back to default. Exported for unit tests.
43
+ */
44
+ export declare function getPostCommitPersistHardTimeoutMs(): number;
45
+ /**
46
+ * TTL (ms) for hard-timed-out markers in the per-store pending-writes
47
+ * tracker. See `pending-writes.ts` for the full lifetime model.
48
+ *
49
+ * An independent TTL with lazy expiry on read bounds marker memory at
50
+ * O(requestRate × TTL) even when the underlying wedged writes never
51
+ * settle (and their `.finally(...)` cleanup therefore never fires).
52
+ * Default 300000ms (5 min) — past this, the best-effort persist
53
+ * contract has long since failed and permanent 404 is the correct
54
+ * eventual outcome. Override via `MLX_HARD_TIMEOUT_MARKER_TTL_MS`
55
+ * (same parse semantics as the hard-timeout env var above). Exported
56
+ * for unit tests.
57
+ */
58
+ export declare function getHardTimedOutMarkerTtlMs(): number;
59
+ export declare function getServerBootId(): string;
60
+ export declare function __setServerBootIdForTesting(id: string): void;
61
+ /**
62
+ * Walk the full `messages` history, validate each assistant fan-out's
63
+ * tool-result block, and canonicalize each block to sibling order in
64
+ * place. Invoked on stateless cold-start histories and on the
65
+ * Anthropic `/v1/messages` endpoint (both feed caller-supplied tool
66
+ * order straight into `primeHistory()` without the continuation gate).
67
+ *
68
+ * Validation rejects: orphan tool messages, unknown `toolCallId`s,
69
+ * missing/duplicate resolutions, and a trailing unresolved fan-out in
70
+ * a stateless history. Returns `null` on success or a human-readable
71
+ * error string (sent as 400 `invalid_request_error`).
72
+ *
73
+ * @param apiSurface controls error-string vocabulary (`openai` default
74
+ * uses `function_call_output` / `call_id`; `anthropic` uses
75
+ * `tool_result` / `tool_use_id`). Validation logic is identical.
76
+ */
77
+ export declare function validateAndCanonicalizeHistoryToolOrder(messages: ChatMessage[], apiSurface?: 'openai' | 'anthropic'): string | null;
78
+ export declare function handleCreateResponse(res: ServerResponse, body: ResponsesAPIRequest, registry: ModelRegistry, store: ResponseStore | null, httpReq?: IncomingMessage, responseRetentionSec?: number): Promise<void>;
79
+ //# sourceMappingURL=responses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../src/endpoints/responses.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAc,WAAW,EAAc,aAAa,EAAwB,MAAM,gBAAgB,CAAC;AAa/G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,OAAO,KAAK,EAMV,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,aAAa,GAAG,OAAO,CAAC;AAmCjE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iCAAiC,IAAI,MAAM,CAM1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAMnD;AA0BD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAyzBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,WAAW,EAAE,EACvB,UAAU,GAAE,QAAQ,GAAG,WAAsB,GAC5C,MAAM,GAAG,IAAI,CAiHf;AAmQD,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,mBAAmB,EACzB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GAAG,IAAI,EAC3B,OAAO,CAAC,EAAE,eAAe,EACzB,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAAC,IAAI,CAAC,CAowDf"}