@mlx-node/server 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Path-based router for /v1/* endpoints. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
import type { ResponseStore } from '@mlx-node/core';
|
|
6
|
+
|
|
7
|
+
import { handleCountMessageTokens } from './endpoints/messages-count-tokens.js';
|
|
8
|
+
import { handleCreateMessage } from './endpoints/messages.js';
|
|
9
|
+
import { handleListModels } from './endpoints/models.js';
|
|
10
|
+
import { handleCreateResponse } from './endpoints/responses.js';
|
|
11
|
+
import {
|
|
12
|
+
sendAnthropicBadRequest,
|
|
13
|
+
sendAnthropicMethodNotAllowed,
|
|
14
|
+
sendBadRequest,
|
|
15
|
+
sendMethodNotAllowed,
|
|
16
|
+
sendNotFound,
|
|
17
|
+
} from './errors.js';
|
|
18
|
+
import type { PublicModelEntry } from './handler.js';
|
|
19
|
+
import { toMinimalHealth, type ServerHealth } from './health.js';
|
|
20
|
+
import type { IdleSweeper } from './idle-sweeper.js';
|
|
21
|
+
import type { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
22
|
+
import type { ModelRegistry } from './registry.js';
|
|
23
|
+
import type { AnthropicCountTokensRequest, AnthropicMessagesRequest } from './types-anthropic.js';
|
|
24
|
+
import type { ResponsesAPIRequest } from './types.js';
|
|
25
|
+
|
|
26
|
+
/** Max request body size (10 MB). */
|
|
27
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The request's pathname, parsed against a CONSTANT base.
|
|
31
|
+
*
|
|
32
|
+
* Never against `Host`. That header is attacker-controlled text on every
|
|
33
|
+
* request — `Host: [` makes `new URL()` throw `ERR_INVALID_URL`, and a throw
|
|
34
|
+
* from an async request listener is an unhandled rejection, which under Node's
|
|
35
|
+
* default `--unhandled-rejections=throw` takes the whole process down. One
|
|
36
|
+
* malformed byte from any client that can reach the socket was enough to end
|
|
37
|
+
* inference. A pathname does not depend on the authority anyway, so a fixed
|
|
38
|
+
* base is both safer and equivalent: an absolute-form request URI
|
|
39
|
+
* (`GET http://host/v1/models HTTP/1.1`, legal in HTTP/1.1) still wins over
|
|
40
|
+
* the base and yields the same path it always did.
|
|
41
|
+
*
|
|
42
|
+
* `req.url` itself is guarded too, for the same reason rather than a known
|
|
43
|
+
* input: this function's contract is that no request can make it throw.
|
|
44
|
+
*/
|
|
45
|
+
export function requestPathname(req: IncomingMessage): string {
|
|
46
|
+
const raw = req.url ?? '/';
|
|
47
|
+
try {
|
|
48
|
+
return new URL(raw, 'http://localhost').pathname;
|
|
49
|
+
} catch {
|
|
50
|
+
const cut = raw.search(/[?#]/);
|
|
51
|
+
const path = cut === -1 ? raw : raw.slice(0, cut);
|
|
52
|
+
return path.startsWith('/') ? path : '/';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks: Buffer[] = [];
|
|
59
|
+
let totalBytes = 0;
|
|
60
|
+
req.on('data', (chunk: Buffer) => {
|
|
61
|
+
totalBytes += chunk.length;
|
|
62
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
63
|
+
reject(new Error('Request body too large'));
|
|
64
|
+
req.destroy();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
});
|
|
69
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Trailing options bag. Added as an object rather than two more positional
|
|
76
|
+
* parameters — `routeRequest` already carries nine, and the two knobs here
|
|
77
|
+
* are unrelated to each other.
|
|
78
|
+
*/
|
|
79
|
+
export interface RouteExtras {
|
|
80
|
+
/** Builds the `/health` body. Omitted ⇒ the legacy constant `{ status: 'ok' }`. */
|
|
81
|
+
health?: () => ServerHealth;
|
|
82
|
+
/**
|
|
83
|
+
* Whether the caller presented a valid token. Only consulted by `/health`,
|
|
84
|
+
* which is the one route reachable without one. `true` when no token is
|
|
85
|
+
* configured at all, so an unprotected server keeps serving the full body.
|
|
86
|
+
*/
|
|
87
|
+
authenticated?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function routeRequest(
|
|
91
|
+
req: IncomingMessage,
|
|
92
|
+
res: ServerResponse,
|
|
93
|
+
registry: ModelRegistry,
|
|
94
|
+
store: ResponseStore | null,
|
|
95
|
+
responseRetentionSec?: number,
|
|
96
|
+
idleSweeper?: IdleSweeper | null,
|
|
97
|
+
resolveModel?: (name: string) => Promise<void>,
|
|
98
|
+
listModels?: () => PublicModelEntry[],
|
|
99
|
+
modelWorkCoordinator?: ModelWorkCoordinator,
|
|
100
|
+
extras?: RouteExtras,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const path = requestPathname(req);
|
|
103
|
+
|
|
104
|
+
if (path === '/v1/models') {
|
|
105
|
+
if (req.method !== 'GET') {
|
|
106
|
+
sendMethodNotAllowed(res, 'GET');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
handleListModels(res, registry, listModels);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (path === '/v1/responses') {
|
|
114
|
+
if (req.method !== 'POST') {
|
|
115
|
+
sendMethodNotAllowed(res, 'POST');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let body: ResponsesAPIRequest;
|
|
120
|
+
try {
|
|
121
|
+
const raw = await readBody(req);
|
|
122
|
+
body = JSON.parse(raw) as ResponsesAPIRequest;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg =
|
|
125
|
+
err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
|
|
126
|
+
sendBadRequest(res, msg);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await handleCreateResponse(
|
|
131
|
+
res,
|
|
132
|
+
body,
|
|
133
|
+
registry,
|
|
134
|
+
store,
|
|
135
|
+
req,
|
|
136
|
+
responseRetentionSec,
|
|
137
|
+
idleSweeper,
|
|
138
|
+
modelWorkCoordinator,
|
|
139
|
+
resolveModel,
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (path === '/v1/messages/count_tokens') {
|
|
145
|
+
if (req.method !== 'POST') {
|
|
146
|
+
sendAnthropicMethodNotAllowed(res, 'POST');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let body: AnthropicCountTokensRequest;
|
|
151
|
+
try {
|
|
152
|
+
const raw = await readBody(req);
|
|
153
|
+
body = JSON.parse(raw) as AnthropicCountTokensRequest;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const msg =
|
|
156
|
+
err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
|
|
157
|
+
sendAnthropicBadRequest(res, msg);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await handleCountMessageTokens(res, body, registry, idleSweeper, resolveModel, modelWorkCoordinator);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (path === '/v1/messages') {
|
|
166
|
+
if (req.method !== 'POST') {
|
|
167
|
+
sendAnthropicMethodNotAllowed(res, 'POST');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let body: AnthropicMessagesRequest;
|
|
172
|
+
try {
|
|
173
|
+
const raw = await readBody(req);
|
|
174
|
+
body = JSON.parse(raw) as AnthropicMessagesRequest;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const msg =
|
|
177
|
+
err instanceof Error && err.message === 'Request body too large' ? err.message : 'Invalid JSON in request body';
|
|
178
|
+
sendAnthropicBadRequest(res, msg);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await handleCreateMessage(res, body, registry, req, idleSweeper, resolveModel, modelWorkCoordinator);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (path === '/health' || path === '/v1/health') {
|
|
187
|
+
// Deliberately NOT bracketed by `idleSweeper.beginRequest/endRequest`
|
|
188
|
+
// and free of any native call: a supervisor polling on an interval must
|
|
189
|
+
// not keep pushing the drain timer out, nor touch the MLX allocator.
|
|
190
|
+
// Every field is read from plain JavaScript state.
|
|
191
|
+
const health = extras?.health?.();
|
|
192
|
+
if (health === undefined) {
|
|
193
|
+
// No reporter wired (a bare `createHandler` mounted by hand): keep the
|
|
194
|
+
// historical constant so existing consumers are unaffected.
|
|
195
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Unauthenticated pollers get liveness only. `models.resident` leaks
|
|
200
|
+
// project names and local paths, so it stays behind the token.
|
|
201
|
+
const body = extras?.authenticated === false ? toMinimalHealth(health) : health;
|
|
202
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
203
|
+
res.end(JSON.stringify(body));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Liveness probe at `/`. Claude Code issues `HEAD /` before its first
|
|
208
|
+
// request; respond 200 so the probe doesn't leave a 404 in the logs.
|
|
209
|
+
if (path === '/') {
|
|
210
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
211
|
+
sendMethodNotAllowed(res, 'GET, HEAD');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
215
|
+
res.end(req.method === 'HEAD' ? undefined : JSON.stringify({ service: 'mlx-node' }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
sendNotFound(res, `No route matches ${req.method} ${path}`);
|
|
220
|
+
}
|