@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
|
@@ -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"}
|