@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/dist/host/discover.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Discover locally-downloaded generative models under a given directory. */
|
|
2
|
-
import {
|
|
2
|
+
import type { LaunchPreset, ModelType } from '@mlx-node/lm/family-data';
|
|
3
3
|
/** A locally-downloaded model paired with its sampling preset. */
|
|
4
4
|
export interface DiscoveredModel {
|
|
5
5
|
name: string;
|
|
@@ -8,11 +8,8 @@ export interface DiscoveredModel {
|
|
|
8
8
|
preset: LaunchPreset;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Non-generative types are silently skipped. Entries with no preset or an
|
|
14
|
-
* undetectable config are skipped (warnings only emitted when `MLX_DEBUG`
|
|
15
|
-
* is set). Must stay cheap — do not load weights here.
|
|
11
|
+
* Use the same checkpoint IDs and paths as the agent and setup UI, including
|
|
12
|
+
* supported GGUF files and their quant variants. No weights are loaded here.
|
|
16
13
|
*/
|
|
17
14
|
export declare function discoverModels(dir: string): Promise<DiscoveredModel[]>;
|
|
18
15
|
//# sourceMappingURL=discover.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/host/discover.ts"],"names":[],"mappings":"AAAA,6EAA6E;
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/host/discover.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAE7E,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAGxE,kEAAkE;AAClE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAO5E"}
|
package/dist/host/discover.js
CHANGED
|
@@ -1,47 +1,14 @@
|
|
|
1
1
|
/** Discover locally-downloaded generative models under a given directory. */
|
|
2
|
-
import {
|
|
3
|
-
import { basename, join } from 'node:path';
|
|
4
|
-
import { detectModelType, NON_GENERATIVE_FAMILY_IDS, launchPresetFor, } from '@mlx-node/lm';
|
|
2
|
+
import { discoverLocalChatModels } from '@mlx-node/lm/model-discovery';
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Non-generative types are silently skipped. Entries with no preset or an
|
|
9
|
-
* undetectable config are skipped (warnings only emitted when `MLX_DEBUG`
|
|
10
|
-
* is set). Must stay cheap — do not load weights here.
|
|
4
|
+
* Use the same checkpoint IDs and paths as the agent and setup UI, including
|
|
5
|
+
* supported GGUF files and their quant variants. No weights are loaded here.
|
|
11
6
|
*/
|
|
12
7
|
export async function discoverModels(dir) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return [];
|
|
20
|
-
}
|
|
21
|
-
const out = [];
|
|
22
|
-
for (const entry of entries) {
|
|
23
|
-
if (!entry.isDirectory())
|
|
24
|
-
continue;
|
|
25
|
-
const full = join(dir, entry.name);
|
|
26
|
-
let modelType;
|
|
27
|
-
try {
|
|
28
|
-
modelType = await detectModelType(full);
|
|
29
|
-
}
|
|
30
|
-
catch (err) {
|
|
31
|
-
if (debug)
|
|
32
|
-
console.warn(`[mlx] skip ${full}: ${err.message}`);
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
if (NON_GENERATIVE_FAMILY_IDS.has(modelType))
|
|
36
|
-
continue;
|
|
37
|
-
const preset = launchPresetFor(modelType);
|
|
38
|
-
if (!preset) {
|
|
39
|
-
if (debug)
|
|
40
|
-
console.warn(`[mlx] skip ${full}: no LAUNCH_PRESETS entry for ${modelType}`);
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
out.push({ name: basename(full), path: full, modelType, preset });
|
|
44
|
-
}
|
|
45
|
-
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
46
|
-
return out;
|
|
8
|
+
return (await discoverLocalChatModels(dir)).map(({ name, path, modelType, preset }) => ({
|
|
9
|
+
name,
|
|
10
|
+
path,
|
|
11
|
+
modelType,
|
|
12
|
+
preset,
|
|
13
|
+
}));
|
|
47
14
|
}
|
package/dist/host/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* after it.
|
|
39
39
|
*/
|
|
40
40
|
import type { Server } from 'node:http';
|
|
41
|
-
import { type LoadableModel } from '@mlx-node/lm';
|
|
41
|
+
import { type LoadableModel, type LoadModelOptions } from '@mlx-node/lm';
|
|
42
42
|
import type { ServerHealth } from '../health.js';
|
|
43
43
|
import { type CloseOptions, type ServerInstance } from '../server.js';
|
|
44
44
|
import { type DiscoveredModel } from './discover.js';
|
|
@@ -134,7 +134,7 @@ export interface InferenceHostOptions {
|
|
|
134
134
|
*/
|
|
135
135
|
sweepOrphanTempRoots?: boolean;
|
|
136
136
|
/** Test seam: replaces the native model loader. */
|
|
137
|
-
loadModel?: (path: string) => Promise<LoadableModel>;
|
|
137
|
+
loadModel?: (path: string, options?: LoadModelOptions) => Promise<LoadableModel>;
|
|
138
138
|
/** Test seam: replaces the verbose request logger. */
|
|
139
139
|
attachLogger?: (server: Server, logDir: string) => Logger;
|
|
140
140
|
}
|
package/dist/host/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,EAIL,KAAK,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"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"}
|
package/dist/host/index.js
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* after it.
|
|
39
39
|
*/
|
|
40
40
|
import { loadModel as loadModelNative, PagedConfigOverrideManager, QWEN35_PAGED_MODEL_TYPES, } from '@mlx-node/lm';
|
|
41
|
+
import { findDFlash2Draft } from '@mlx-node/lm/draft-companion';
|
|
41
42
|
import { createServer, resolveAuthToken } from '../server.js';
|
|
42
43
|
import { discoverModels } from './discover.js';
|
|
43
44
|
import { applyEnginePolicy } from './env-policy.js';
|
|
@@ -178,7 +179,13 @@ export async function createInferenceHost(opts = {}) {
|
|
|
178
179
|
tempDirPrefix: hostTempDirPrefix(),
|
|
179
180
|
});
|
|
180
181
|
const loadModelFn = opts.loadModel ?? loadModelNative;
|
|
181
|
-
const loadModelPagedAware = async (path) =>
|
|
182
|
+
const loadModelPagedAware = async (path) => {
|
|
183
|
+
const entry = models.find((model) => model.path === path);
|
|
184
|
+
// Resolve against the source directory before creating the paged config overlay.
|
|
185
|
+
const draftModelPath = findDFlash2Draft(path, entry.modelType, modelsDir);
|
|
186
|
+
const resolvedPath = await pagedConfigOverrides.resolve(path);
|
|
187
|
+
return draftModelPath === undefined ? loadModelFn(resolvedPath) : loadModelFn(resolvedPath, { draftModelPath });
|
|
188
|
+
};
|
|
182
189
|
const controller = makeSwapController(models, server.registry, loadModelPagedAware, boundEntry.name);
|
|
183
190
|
ctrlRef.current = controller;
|
|
184
191
|
// Attach AFTER `createServer` so the wrapper sees every incoming request,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlx-node/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"homepage": "https://github.com/mlx-node/mlx-node",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/mlx-node/mlx-node/issues"
|
|
@@ -12,25 +12,30 @@
|
|
|
12
12
|
"directory": "packages/server"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"dist"
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
16
17
|
],
|
|
17
18
|
"type": "module",
|
|
18
19
|
"main": "./dist/index.js",
|
|
19
20
|
"types": "./dist/index.d.ts",
|
|
20
21
|
"exports": {
|
|
21
22
|
".": {
|
|
23
|
+
"@mlx-node/source": "./src/index.ts",
|
|
22
24
|
"types": "./dist/index.d.ts",
|
|
23
25
|
"import": "./dist/index.js"
|
|
24
26
|
},
|
|
25
27
|
"./host": {
|
|
28
|
+
"@mlx-node/source": "./src/host/index.ts",
|
|
26
29
|
"types": "./dist/host/index.d.ts",
|
|
27
30
|
"import": "./dist/host/index.js"
|
|
28
31
|
},
|
|
29
32
|
"./host/env-policy": {
|
|
33
|
+
"@mlx-node/source": "./src/host/env-policy.ts",
|
|
30
34
|
"types": "./dist/host/env-policy.d.ts",
|
|
31
35
|
"import": "./dist/host/env-policy.js"
|
|
32
36
|
},
|
|
33
37
|
"./host/paths": {
|
|
38
|
+
"@mlx-node/source": "./src/host/paths.ts",
|
|
34
39
|
"types": "./dist/host/paths.d.ts",
|
|
35
40
|
"import": "./dist/host/paths.js"
|
|
36
41
|
}
|
|
@@ -39,8 +44,8 @@
|
|
|
39
44
|
"build": "tsc -b"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
42
|
-
"@mlx-node/core": "0.0.
|
|
43
|
-
"@mlx-node/lm": "0.0.
|
|
47
|
+
"@mlx-node/core": "0.0.15",
|
|
48
|
+
"@mlx-node/lm": "0.0.15"
|
|
44
49
|
},
|
|
45
50
|
"devDependencies": {
|
|
46
51
|
"@types/node": "@types/node@26.4.0"
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional bearer-token gate.
|
|
3
|
+
*
|
|
4
|
+
* Enforced at the single choke point in `createHandler`'s returned closure —
|
|
5
|
+
* after the CORS/OPTIONS early-return, before `routeRequest` — so there is
|
|
6
|
+
* exactly one place a route can be added without inheriting the gate.
|
|
7
|
+
*
|
|
8
|
+
* Scope, deliberately narrow: this is a shared-secret check for a server
|
|
9
|
+
* bound to loopback and supervised by a local app. It is NOT a
|
|
10
|
+
* multi-tenant auth system — there is one token, no rotation, no scopes,
|
|
11
|
+
* no per-caller identity.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
16
|
+
|
|
17
|
+
/** `Bearer ` prefix length, used to slice the credential out of `authorization`. */
|
|
18
|
+
const BEARER_PREFIX = 'bearer ';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract the presented credential, or `null` when none is usable.
|
|
22
|
+
*
|
|
23
|
+
* `x-api-key` is checked FIRST because Anthropic clients (including Claude
|
|
24
|
+
* Code, the primary consumer of `/v1/messages`) send it. Checking it first
|
|
25
|
+
* also means a stale `authorization` header injected by an intermediary
|
|
26
|
+
* cannot shadow the caller's real key.
|
|
27
|
+
*
|
|
28
|
+
* Array-valued headers are rejected outright. Node collapses repeated
|
|
29
|
+
* non-allowlisted headers such as `x-api-key` into a `string[]`; joining or
|
|
30
|
+
* picking one arbitrarily would let a caller smuggle a second value past a
|
|
31
|
+
* front proxy that only inspected the first.
|
|
32
|
+
*/
|
|
33
|
+
export function extractPresentedToken(req: IncomingMessage): string | null {
|
|
34
|
+
const apiKey = req.headers['x-api-key'];
|
|
35
|
+
if (apiKey !== undefined) {
|
|
36
|
+
return typeof apiKey === 'string' ? apiKey : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const authorization = req.headers.authorization;
|
|
40
|
+
if (typeof authorization !== 'string') return null;
|
|
41
|
+
// Scheme is case-insensitive per RFC 7235; the credential is not.
|
|
42
|
+
if (authorization.length <= BEARER_PREFIX.length) return null;
|
|
43
|
+
if (authorization.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return null;
|
|
44
|
+
return authorization.slice(BEARER_PREFIX.length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Constant-time-ish credential comparison.
|
|
49
|
+
*
|
|
50
|
+
* The length check in front of `timingSafeEqual` is unavoidable — the
|
|
51
|
+
* primitive throws on mismatched lengths. It therefore LEAKS the length of
|
|
52
|
+
* the configured token to an attacker who can time responses. That is an
|
|
53
|
+
* accepted trade: the token is a locally-generated high-entropy secret, and
|
|
54
|
+
* knowing its length does not meaningfully reduce the search space. The
|
|
55
|
+
* byte-by-byte content comparison, which is the part that would otherwise
|
|
56
|
+
* allow incremental guessing, stays constant-time.
|
|
57
|
+
*/
|
|
58
|
+
export function tokensMatch(presented: string, expected: string): boolean {
|
|
59
|
+
// The empty string is not a credential, in either position. `timingSafeEqual`
|
|
60
|
+
// on two zero-length buffers returns TRUE, so a server misconfigured with an
|
|
61
|
+
// empty token would authenticate every caller who sent an empty `x-api-key`.
|
|
62
|
+
// `resolveAuthToken` rejects an empty token before it can reach a server built
|
|
63
|
+
// through `createServer`; this is the second latch, for a caller that
|
|
64
|
+
// constructs `createHandler` directly. Refusing is the fail-closed direction —
|
|
65
|
+
// an empty token 401s everything rather than admitting everything.
|
|
66
|
+
if (presented === '' || expected === '') return false;
|
|
67
|
+
const presentedBytes = Buffer.from(presented, 'utf8');
|
|
68
|
+
const expectedBytes = Buffer.from(expected, 'utf8');
|
|
69
|
+
if (presentedBytes.length !== expectedBytes.length) return false;
|
|
70
|
+
return timingSafeEqual(presentedBytes, expectedBytes);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `true` when the caller offered SOME credential, valid or not.
|
|
75
|
+
*
|
|
76
|
+
* Used only by the `/health` carve-out: an anonymous poll degrades to a
|
|
77
|
+
* liveness-only body, but a caller who presented a wrong (or malformed)
|
|
78
|
+
* credential gets a 401 so a mistyped token surfaces instead of masquerading
|
|
79
|
+
* as a healthy 200.
|
|
80
|
+
*/
|
|
81
|
+
export function hasCredential(req: IncomingMessage): boolean {
|
|
82
|
+
return req.headers['x-api-key'] !== undefined || req.headers.authorization !== undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `true` when the request carries a credential matching `expected`. */
|
|
86
|
+
export function isAuthorized(req: IncomingMessage, expected: string): boolean {
|
|
87
|
+
const presented = extractPresentedToken(req);
|
|
88
|
+
if (presented === null) return false;
|
|
89
|
+
return tokensMatch(presented, expected);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 401 with `WWW-Authenticate: Bearer`. The body deliberately says nothing
|
|
94
|
+
* about whether a credential was absent, malformed, or simply wrong.
|
|
95
|
+
*/
|
|
96
|
+
export function sendUnauthorized(res: ServerResponse): void {
|
|
97
|
+
res.writeHead(401, {
|
|
98
|
+
'WWW-Authenticate': 'Bearer realm="mlx-node"',
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
});
|
|
101
|
+
res.end(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
error: {
|
|
104
|
+
type: 'authentication_error',
|
|
105
|
+
message: 'Missing or invalid API key',
|
|
106
|
+
code: null,
|
|
107
|
+
param: null,
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-private `ChatSession` warm-reuse helper.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the `@mlx-node/lm` package exports entirely so downstream
|
|
5
|
+
* consumers cannot reach it: the module lives inside
|
|
6
|
+
* `@mlx-node/server`, its file path is not on the server's export map,
|
|
7
|
+
* and nothing re-exports it. Callers are the two server endpoints —
|
|
8
|
+
* `endpoints/responses.ts` (tier-1 / tier-2 HIT branch) and
|
|
9
|
+
* `endpoints/messages.ts` (`getOrCreateWarmAny` HIT branch) — each
|
|
10
|
+
* invoking the helper only when native cache reuse is authorized: either a
|
|
11
|
+
* `SessionRegistry` hit or a block-paged model whose allocator validates
|
|
12
|
+
* reusable prefixes by content hash and isolates live cache owners.
|
|
13
|
+
*
|
|
14
|
+
* Why this helper exists at all: public `ChatSession.reset()` invalidates the
|
|
15
|
+
* current session's native state — owner-scoped on block-paged models and
|
|
16
|
+
* model-wide on exclusive/flat models. A warm registry HIT instead needs to
|
|
17
|
+
* preserve the already-authorized native prefix while clearing only the JS
|
|
18
|
+
* conversation wrapper. Warm leases provide the ownership gate; block-paged
|
|
19
|
+
* adapters provide the content-verification gate. A JS-state-only reset is
|
|
20
|
+
* valid in either case.
|
|
21
|
+
*
|
|
22
|
+
* Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
|
|
23
|
+
* `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
|
|
24
|
+
* These are TypeScript `private` fields on `ChatSession` (compile-time
|
|
25
|
+
* only) — at runtime they are ordinary properties. The cast through
|
|
26
|
+
* {@link ChatSessionWarmReuseInternals} gives this helper a typed view
|
|
27
|
+
* of the instance without relaxing the class's `private` declarations.
|
|
28
|
+
* The field names MUST stay in sync with
|
|
29
|
+
* `packages/lm/src/chat-session.ts`; a mismatch would silently skip
|
|
30
|
+
* the intended state wipe and is covered by the warm-reuse unit tests.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ChatConfig, ChatSession, SessionCapableModel } from '@mlx-node/lm';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Private structural view of the `ChatSession` JS-side state that the
|
|
37
|
+
* warm-reuse helper needs to wipe. Mirrors the internal state
|
|
38
|
+
* documented on `ChatSession` itself — field names are load-bearing:
|
|
39
|
+
* they must byte-match the concrete class's private fields or the
|
|
40
|
+
* cast-based mutation below silently no-ops.
|
|
41
|
+
*/
|
|
42
|
+
interface ChatSessionWarmReuseInternals {
|
|
43
|
+
inFlight: boolean;
|
|
44
|
+
history: unknown[];
|
|
45
|
+
lastImagesKey: string | null;
|
|
46
|
+
lastAudioKey: string | null;
|
|
47
|
+
turnCount: number;
|
|
48
|
+
unresolvedOkToolCallCount: number | null;
|
|
49
|
+
needsFullReplay: boolean;
|
|
50
|
+
defaultConfig?: ChatConfig;
|
|
51
|
+
activeTools: ChatConfig['tools'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* JS-state-only reset that DELIBERATELY preserves the underlying
|
|
56
|
+
* model's native KV cache and `cached_token_history`.
|
|
57
|
+
*
|
|
58
|
+
* @internal server-private — used only by `SessionRegistry` HIT
|
|
59
|
+
* branches in `endpoints/responses.ts` and `endpoints/messages.ts`.
|
|
60
|
+
* Never export from this package's `index.ts`.
|
|
61
|
+
*
|
|
62
|
+
* Wipes ONLY the JS-side session state (history array, image key, turn
|
|
63
|
+
* counter, tool-call fan-out guard). With this function, the JS session
|
|
64
|
+
* is fresh enough for `ChatSession.primeHistory()` (which requires
|
|
65
|
+
* `turnCount === 0`) while the native prefix verifier can still recover
|
|
66
|
+
* the reused prefix on the next `chatSessionStart` and skip the
|
|
67
|
+
* corresponding re-prefill.
|
|
68
|
+
*/
|
|
69
|
+
export async function resetPreservingNativeCacheForWarmReuse<M extends SessionCapableModel>(
|
|
70
|
+
session: ChatSession<M>,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
// TypeScript `private` fields are only compile-time checks; at
|
|
73
|
+
// runtime they are ordinary properties. The cast through
|
|
74
|
+
// `ChatSessionWarmReuseInternals` preserves full static typing for
|
|
75
|
+
// this helper's mutations while bypassing the `private` gate — which
|
|
76
|
+
// is correct here because this helper is the designated server-side
|
|
77
|
+
// friend accessor. The cast is funneled through `unknown` because TS
|
|
78
|
+
// correctly rejects a direct `ChatSession → Internals` cast when the
|
|
79
|
+
// concrete class has other non-internals fields.
|
|
80
|
+
const internals = session as unknown as ChatSessionWarmReuseInternals;
|
|
81
|
+
if (internals.inFlight) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'ChatSession: cannot resetPreservingNativeCacheForWarmReuse() while a send() is in flight; await the previous call first',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
internals.history = [];
|
|
87
|
+
internals.lastImagesKey = null;
|
|
88
|
+
internals.lastAudioKey = null;
|
|
89
|
+
internals.turnCount = 0;
|
|
90
|
+
internals.unresolvedOkToolCallCount = null;
|
|
91
|
+
internals.needsFullReplay = false;
|
|
92
|
+
// Tools are conversation state. A warm-any lease may belong to an
|
|
93
|
+
// unrelated request, so restore constructor defaults exactly like
|
|
94
|
+
// ChatSession.reset() instead of leaking the prior committed overlay.
|
|
95
|
+
internals.activeTools = internals.defaultConfig?.tools;
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** POST /v1/messages/count_tokens — Anthropic Messages token-count endpoint. */
|
|
2
|
+
|
|
3
|
+
import type { ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
import type { ChatMessage, ToolDefinition } from '@mlx-node/core';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
sendAnthropicBadRequest,
|
|
9
|
+
sendAnthropicInternalError,
|
|
10
|
+
sendAnthropicNotFound,
|
|
11
|
+
sendAnthropicNotImplemented,
|
|
12
|
+
} from '../errors.js';
|
|
13
|
+
import type { IdleSweeper } from '../idle-sweeper.js';
|
|
14
|
+
import { mapAnthropicRequest } from '../mappers/anthropic-request.js';
|
|
15
|
+
import type { ModelWorkCoordinator } from '../model-work-coordinator.js';
|
|
16
|
+
import type { ModelRegistry, ServableModel } from '../registry.js';
|
|
17
|
+
import type { AnthropicCountTokensRequest, AnthropicCountTokensResponse } from '../types-anthropic.js';
|
|
18
|
+
|
|
19
|
+
interface ChatTemplateTokenCounter {
|
|
20
|
+
applyChatTemplate(
|
|
21
|
+
messages: ChatMessage[],
|
|
22
|
+
addGenerationPrompt?: boolean | null,
|
|
23
|
+
tools?: ToolDefinition[] | null,
|
|
24
|
+
enableThinking?: boolean | null,
|
|
25
|
+
): Promise<Uint32Array> | Uint32Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getChatTemplateTokenCounter(model: ServableModel): ChatTemplateTokenCounter | null {
|
|
29
|
+
const candidate = model as ServableModel & Partial<ChatTemplateTokenCounter>;
|
|
30
|
+
return typeof candidate.applyChatTemplate === 'function' ? (candidate as ChatTemplateTokenCounter) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endJson(res: ServerResponse, body: AnthropicCountTokensResponse): void {
|
|
34
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify(body));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleCountMessageTokens(
|
|
39
|
+
res: ServerResponse,
|
|
40
|
+
body: AnthropicCountTokensRequest,
|
|
41
|
+
registry: ModelRegistry,
|
|
42
|
+
idleSweeper?: IdleSweeper | null,
|
|
43
|
+
resolveModel?: (name: string) => Promise<void>,
|
|
44
|
+
modelWorkCoordinator?: ModelWorkCoordinator,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (body == null || typeof body !== 'object') {
|
|
47
|
+
sendAnthropicBadRequest(res, 'Request body must be a JSON object');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!body.model) {
|
|
51
|
+
sendAnthropicBadRequest(res, 'Missing required field: model');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
|
55
|
+
sendAnthropicBadRequest(res, 'Missing required field: messages');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const msg of body.messages) {
|
|
60
|
+
if (msg == null || typeof msg !== 'object') {
|
|
61
|
+
sendAnthropicBadRequest(res, 'Each message must be a non-null object');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let mapped: ReturnType<typeof mapAnthropicRequest>;
|
|
67
|
+
try {
|
|
68
|
+
mapped = mapAnthropicRequest(body);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (resolveModel) {
|
|
75
|
+
try {
|
|
76
|
+
const runResolve = () =>
|
|
77
|
+
idleSweeper ? idleSweeper.withSuspendedDrains(() => resolveModel(body.model)) : resolveModel(body.model);
|
|
78
|
+
if (modelWorkCoordinator) await modelWorkCoordinator.withModelLoad(runResolve);
|
|
79
|
+
else await runResolve();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lease = registry.acquireDispatchLease(body.model);
|
|
87
|
+
if (!lease) {
|
|
88
|
+
if (registry.get(body.model) != null) {
|
|
89
|
+
sendAnthropicInternalError(res, 'session registry missing for registered model');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
sendAnthropicNotFound(res, `Model "${body.model}" not found`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const leaseModel = lease.model;
|
|
96
|
+
const sessionReg = lease.registry;
|
|
97
|
+
const preLockInstanceId = lease.instanceId;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Token counting is a pure-CPU tokenize-and-template operation; it must NOT
|
|
101
|
+
// queue behind the per-model generation FIFO (`sessionReg.withExclusive`)
|
|
102
|
+
// because that serializes against multi-minute decode passes and turns a
|
|
103
|
+
// millisecond call into a multi-hundred-second wait. The dispatch lease
|
|
104
|
+
// already pins the model object + binding for the duration of this call,
|
|
105
|
+
// and `withInference` (a shared reader lock against `withModelLoad`) is
|
|
106
|
+
// enough to keep the model from being swapped out mid-tokenize.
|
|
107
|
+
const bindingStillMatchesLease = () => {
|
|
108
|
+
const lockedSessionReg = registry.getSessionRegistry(body.model);
|
|
109
|
+
const lockedInstanceId = registry.getInstanceId(body.model);
|
|
110
|
+
return (
|
|
111
|
+
lockedSessionReg !== undefined &&
|
|
112
|
+
lockedInstanceId !== undefined &&
|
|
113
|
+
lockedSessionReg === sessionReg &&
|
|
114
|
+
lockedInstanceId === preLockInstanceId
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const rejectChangedBinding = (phase: string) => {
|
|
119
|
+
sendAnthropicBadRequest(
|
|
120
|
+
res,
|
|
121
|
+
`Model "${body.model}" binding changed while the token-count request was ${phase}. ` +
|
|
122
|
+
`A concurrent register() re-pointed the name at a different model instance ` +
|
|
123
|
+
`(or released it entirely), so counting against the leased model would use ` +
|
|
124
|
+
`a stale model object. Retry the request — if the swap was intentional, the ` +
|
|
125
|
+
`new binding will service the retry cleanly.`,
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const runCountWithModelRead = async () => {
|
|
130
|
+
if (!bindingStillMatchesLease()) {
|
|
131
|
+
rejectChangedBinding('waiting for the model-load reader gate');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const counter = getChatTemplateTokenCounter(leaseModel);
|
|
136
|
+
if (!counter) {
|
|
137
|
+
sendAnthropicNotImplemented(
|
|
138
|
+
res,
|
|
139
|
+
`Model "${body.model}" does not expose applyChatTemplate(); token counting requires a ` +
|
|
140
|
+
`non-generating chat-template tokenizer API on the registered model.`,
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const tokens = await counter.applyChatTemplate(mapped.messages, true, mapped.config.tools ?? null);
|
|
147
|
+
if (!bindingStillMatchesLease()) {
|
|
148
|
+
rejectChangedBinding('running');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
endJson(res, { input_tokens: tokens.length });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to count tokens');
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (modelWorkCoordinator) await modelWorkCoordinator.withInference(runCountWithModelRead);
|
|
158
|
+
else await runCountWithModelRead();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to count tokens');
|
|
161
|
+
} finally {
|
|
162
|
+
registry.releaseDispatchLease(leaseModel);
|
|
163
|
+
}
|
|
164
|
+
}
|