@mlx-node/server 0.0.7 → 0.0.9
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/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +72 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +22 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +608 -85
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +69 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +70 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +95 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +201 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +37 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +79 -7
- package/dist/server.d.ts +193 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +173 -10
- package/dist/session-registry.d.ts +279 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +518 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -3
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
14
|
+
/**
|
|
15
|
+
* Extract the presented credential, or `null` when none is usable.
|
|
16
|
+
*
|
|
17
|
+
* `x-api-key` is checked FIRST because Anthropic clients (including Claude
|
|
18
|
+
* Code, the primary consumer of `/v1/messages`) send it. Checking it first
|
|
19
|
+
* also means a stale `authorization` header injected by an intermediary
|
|
20
|
+
* cannot shadow the caller's real key.
|
|
21
|
+
*
|
|
22
|
+
* Array-valued headers are rejected outright. Node collapses repeated
|
|
23
|
+
* non-allowlisted headers such as `x-api-key` into a `string[]`; joining or
|
|
24
|
+
* picking one arbitrarily would let a caller smuggle a second value past a
|
|
25
|
+
* front proxy that only inspected the first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function extractPresentedToken(req: IncomingMessage): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time-ish credential comparison.
|
|
30
|
+
*
|
|
31
|
+
* The length check in front of `timingSafeEqual` is unavoidable — the
|
|
32
|
+
* primitive throws on mismatched lengths. It therefore LEAKS the length of
|
|
33
|
+
* the configured token to an attacker who can time responses. That is an
|
|
34
|
+
* accepted trade: the token is a locally-generated high-entropy secret, and
|
|
35
|
+
* knowing its length does not meaningfully reduce the search space. The
|
|
36
|
+
* byte-by-byte content comparison, which is the part that would otherwise
|
|
37
|
+
* allow incremental guessing, stays constant-time.
|
|
38
|
+
*/
|
|
39
|
+
export declare function tokensMatch(presented: string, expected: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* `true` when the caller offered SOME credential, valid or not.
|
|
42
|
+
*
|
|
43
|
+
* Used only by the `/health` carve-out: an anonymous poll degrades to a
|
|
44
|
+
* liveness-only body, but a caller who presented a wrong (or malformed)
|
|
45
|
+
* credential gets a 401 so a mistyped token surfaces instead of masquerading
|
|
46
|
+
* as a healthy 200.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hasCredential(req: IncomingMessage): boolean;
|
|
49
|
+
/** `true` when the request carries a credential matching `expected`. */
|
|
50
|
+
export declare function isAuthorized(req: IncomingMessage, expected: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* 401 with `WWW-Authenticate: Bearer`. The body deliberately says nothing
|
|
53
|
+
* about whether a credential was absent, malformed, or simply wrong.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sendUnauthorized(res: ServerResponse): void;
|
|
56
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAKjE;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAYzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAaxE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAE3D;AAED,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAe1D"}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
14
|
+
/** `Bearer ` prefix length, used to slice the credential out of `authorization`. */
|
|
15
|
+
const BEARER_PREFIX = 'bearer ';
|
|
16
|
+
/**
|
|
17
|
+
* Extract the presented credential, or `null` when none is usable.
|
|
18
|
+
*
|
|
19
|
+
* `x-api-key` is checked FIRST because Anthropic clients (including Claude
|
|
20
|
+
* Code, the primary consumer of `/v1/messages`) send it. Checking it first
|
|
21
|
+
* also means a stale `authorization` header injected by an intermediary
|
|
22
|
+
* cannot shadow the caller's real key.
|
|
23
|
+
*
|
|
24
|
+
* Array-valued headers are rejected outright. Node collapses repeated
|
|
25
|
+
* non-allowlisted headers such as `x-api-key` into a `string[]`; joining or
|
|
26
|
+
* picking one arbitrarily would let a caller smuggle a second value past a
|
|
27
|
+
* front proxy that only inspected the first.
|
|
28
|
+
*/
|
|
29
|
+
export function extractPresentedToken(req) {
|
|
30
|
+
const apiKey = req.headers['x-api-key'];
|
|
31
|
+
if (apiKey !== undefined) {
|
|
32
|
+
return typeof apiKey === 'string' ? apiKey : null;
|
|
33
|
+
}
|
|
34
|
+
const authorization = req.headers.authorization;
|
|
35
|
+
if (typeof authorization !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
// Scheme is case-insensitive per RFC 7235; the credential is not.
|
|
38
|
+
if (authorization.length <= BEARER_PREFIX.length)
|
|
39
|
+
return null;
|
|
40
|
+
if (authorization.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX)
|
|
41
|
+
return null;
|
|
42
|
+
return authorization.slice(BEARER_PREFIX.length);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Constant-time-ish credential comparison.
|
|
46
|
+
*
|
|
47
|
+
* The length check in front of `timingSafeEqual` is unavoidable — the
|
|
48
|
+
* primitive throws on mismatched lengths. It therefore LEAKS the length of
|
|
49
|
+
* the configured token to an attacker who can time responses. That is an
|
|
50
|
+
* accepted trade: the token is a locally-generated high-entropy secret, and
|
|
51
|
+
* knowing its length does not meaningfully reduce the search space. The
|
|
52
|
+
* byte-by-byte content comparison, which is the part that would otherwise
|
|
53
|
+
* allow incremental guessing, stays constant-time.
|
|
54
|
+
*/
|
|
55
|
+
export function tokensMatch(presented, expected) {
|
|
56
|
+
// The empty string is not a credential, in either position. `timingSafeEqual`
|
|
57
|
+
// on two zero-length buffers returns TRUE, so a server misconfigured with an
|
|
58
|
+
// empty token would authenticate every caller who sent an empty `x-api-key`.
|
|
59
|
+
// `resolveAuthToken` rejects an empty token before it can reach a server built
|
|
60
|
+
// through `createServer`; this is the second latch, for a caller that
|
|
61
|
+
// constructs `createHandler` directly. Refusing is the fail-closed direction —
|
|
62
|
+
// an empty token 401s everything rather than admitting everything.
|
|
63
|
+
if (presented === '' || expected === '')
|
|
64
|
+
return false;
|
|
65
|
+
const presentedBytes = Buffer.from(presented, 'utf8');
|
|
66
|
+
const expectedBytes = Buffer.from(expected, 'utf8');
|
|
67
|
+
if (presentedBytes.length !== expectedBytes.length)
|
|
68
|
+
return false;
|
|
69
|
+
return timingSafeEqual(presentedBytes, expectedBytes);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `true` when the caller offered SOME credential, valid or not.
|
|
73
|
+
*
|
|
74
|
+
* Used only by the `/health` carve-out: an anonymous poll degrades to a
|
|
75
|
+
* liveness-only body, but a caller who presented a wrong (or malformed)
|
|
76
|
+
* credential gets a 401 so a mistyped token surfaces instead of masquerading
|
|
77
|
+
* as a healthy 200.
|
|
78
|
+
*/
|
|
79
|
+
export function hasCredential(req) {
|
|
80
|
+
return req.headers['x-api-key'] !== undefined || req.headers.authorization !== undefined;
|
|
81
|
+
}
|
|
82
|
+
/** `true` when the request carries a credential matching `expected`. */
|
|
83
|
+
export function isAuthorized(req, expected) {
|
|
84
|
+
const presented = extractPresentedToken(req);
|
|
85
|
+
if (presented === null)
|
|
86
|
+
return false;
|
|
87
|
+
return tokensMatch(presented, expected);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 401 with `WWW-Authenticate: Bearer`. The body deliberately says nothing
|
|
91
|
+
* about whether a credential was absent, malformed, or simply wrong.
|
|
92
|
+
*/
|
|
93
|
+
export function sendUnauthorized(res) {
|
|
94
|
+
res.writeHead(401, {
|
|
95
|
+
'WWW-Authenticate': 'Bearer realm="mlx-node"',
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
});
|
|
98
|
+
res.end(JSON.stringify({
|
|
99
|
+
error: {
|
|
100
|
+
type: 'authentication_error',
|
|
101
|
+
message: 'Missing or invalid API key',
|
|
102
|
+
code: null,
|
|
103
|
+
param: null,
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 exclusively under a `SessionRegistry` HIT gate.
|
|
11
|
+
*
|
|
12
|
+
* Why this helper exists at all: `ChatSession.reset()` is the safe
|
|
13
|
+
* public wipe — it always calls `model.resetCaches()` because the
|
|
14
|
+
* underlying `SessionCapableModel` is shared across every session
|
|
15
|
+
* lifetime via the native `ModelRegistry`. A partial wipe that leaves
|
|
16
|
+
* the shared native KV cache intact without the HIT gate would leak a
|
|
17
|
+
* previous (unrelated) request's cached prefix into the next
|
|
18
|
+
* `chat_session_start_sync` call. The server's warm-lease replay path
|
|
19
|
+
* DOES have that HIT gate — the registry's own `getOrCreate` hit
|
|
20
|
+
* signal is the authoritative proof that the native cache genuinely
|
|
21
|
+
* belongs to this chain — so a JS-state-only reset that preserves the
|
|
22
|
+
* native cache is correct there and only there.
|
|
23
|
+
*
|
|
24
|
+
* Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
|
|
25
|
+
* `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
|
|
26
|
+
* These are TypeScript `private` fields on `ChatSession` (compile-time
|
|
27
|
+
* only) — at runtime they are ordinary properties. The cast through
|
|
28
|
+
* {@link ChatSessionWarmReuseInternals} gives this helper a typed view
|
|
29
|
+
* of the instance without relaxing the class's `private` declarations.
|
|
30
|
+
* The field names MUST stay in sync with
|
|
31
|
+
* `packages/lm/src/chat-session.ts`; a mismatch would silently skip
|
|
32
|
+
* the intended state wipe and is covered by the warm-reuse unit tests.
|
|
33
|
+
*/
|
|
34
|
+
import type { ChatSession, SessionCapableModel } from '@mlx-node/lm';
|
|
35
|
+
/**
|
|
36
|
+
* JS-state-only reset that DELIBERATELY preserves the underlying
|
|
37
|
+
* model's native KV cache and `cached_token_history`.
|
|
38
|
+
*
|
|
39
|
+
* @internal server-private — used only by `SessionRegistry` HIT
|
|
40
|
+
* branches in `endpoints/responses.ts` and `endpoints/messages.ts`.
|
|
41
|
+
* Never export from this package's `index.ts`.
|
|
42
|
+
*
|
|
43
|
+
* Wipes ONLY the JS-side session state (history array, image key, turn
|
|
44
|
+
* counter, tool-call fan-out guard). With this function, the JS session
|
|
45
|
+
* is fresh enough for `ChatSession.primeHistory()` (which requires
|
|
46
|
+
* `turnCount === 0`) while the native prefix verifier can still recover
|
|
47
|
+
* the reused prefix on the next `chatSessionStart` and skip the
|
|
48
|
+
* corresponding re-prefill.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resetPreservingNativeCacheForWarmReuse<M extends SessionCapableModel>(session: ChatSession<M>): Promise<void>;
|
|
51
|
+
//# sourceMappingURL=chat-session-warm-reuse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-session-warm-reuse.d.ts","sourceRoot":"","sources":["../src/chat-session-warm-reuse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAc,WAAW,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAqBjF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,sCAAsC,CAAC,CAAC,SAAS,mBAAmB,EACxF,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAyBf"}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 exclusively under a `SessionRegistry` HIT gate.
|
|
11
|
+
*
|
|
12
|
+
* Why this helper exists at all: `ChatSession.reset()` is the safe
|
|
13
|
+
* public wipe — it always calls `model.resetCaches()` because the
|
|
14
|
+
* underlying `SessionCapableModel` is shared across every session
|
|
15
|
+
* lifetime via the native `ModelRegistry`. A partial wipe that leaves
|
|
16
|
+
* the shared native KV cache intact without the HIT gate would leak a
|
|
17
|
+
* previous (unrelated) request's cached prefix into the next
|
|
18
|
+
* `chat_session_start_sync` call. The server's warm-lease replay path
|
|
19
|
+
* DOES have that HIT gate — the registry's own `getOrCreate` hit
|
|
20
|
+
* signal is the authoritative proof that the native cache genuinely
|
|
21
|
+
* belongs to this chain — so a JS-state-only reset that preserves the
|
|
22
|
+
* native cache is correct there and only there.
|
|
23
|
+
*
|
|
24
|
+
* Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
|
|
25
|
+
* `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
|
|
26
|
+
* These are TypeScript `private` fields on `ChatSession` (compile-time
|
|
27
|
+
* only) — at runtime they are ordinary properties. The cast through
|
|
28
|
+
* {@link ChatSessionWarmReuseInternals} gives this helper a typed view
|
|
29
|
+
* of the instance without relaxing the class's `private` declarations.
|
|
30
|
+
* The field names MUST stay in sync with
|
|
31
|
+
* `packages/lm/src/chat-session.ts`; a mismatch would silently skip
|
|
32
|
+
* the intended state wipe and is covered by the warm-reuse unit tests.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* JS-state-only reset that DELIBERATELY preserves the underlying
|
|
36
|
+
* model's native KV cache and `cached_token_history`.
|
|
37
|
+
*
|
|
38
|
+
* @internal server-private — used only by `SessionRegistry` HIT
|
|
39
|
+
* branches in `endpoints/responses.ts` and `endpoints/messages.ts`.
|
|
40
|
+
* Never export from this package's `index.ts`.
|
|
41
|
+
*
|
|
42
|
+
* Wipes ONLY the JS-side session state (history array, image key, turn
|
|
43
|
+
* counter, tool-call fan-out guard). With this function, the JS session
|
|
44
|
+
* is fresh enough for `ChatSession.primeHistory()` (which requires
|
|
45
|
+
* `turnCount === 0`) while the native prefix verifier can still recover
|
|
46
|
+
* the reused prefix on the next `chatSessionStart` and skip the
|
|
47
|
+
* corresponding re-prefill.
|
|
48
|
+
*/
|
|
49
|
+
export async function resetPreservingNativeCacheForWarmReuse(session) {
|
|
50
|
+
// TypeScript `private` fields are only compile-time checks; at
|
|
51
|
+
// runtime they are ordinary properties. The cast through
|
|
52
|
+
// `ChatSessionWarmReuseInternals` preserves full static typing for
|
|
53
|
+
// this helper's mutations while bypassing the `private` gate — which
|
|
54
|
+
// is correct here because this helper is the designated server-side
|
|
55
|
+
// friend accessor. The cast is funneled through `unknown` because TS
|
|
56
|
+
// correctly rejects a direct `ChatSession → Internals` cast when the
|
|
57
|
+
// concrete class has other non-internals fields.
|
|
58
|
+
const internals = session;
|
|
59
|
+
if (internals.inFlight) {
|
|
60
|
+
throw new Error('ChatSession: cannot resetPreservingNativeCacheForWarmReuse() while a send() is in flight; await the previous call first');
|
|
61
|
+
}
|
|
62
|
+
internals.history = [];
|
|
63
|
+
internals.lastImagesKey = null;
|
|
64
|
+
internals.lastAudioKey = null;
|
|
65
|
+
internals.turnCount = 0;
|
|
66
|
+
internals.unresolvedOkToolCallCount = null;
|
|
67
|
+
internals.needsFullReplay = false;
|
|
68
|
+
// Tools are conversation state. A warm-any lease may belong to an
|
|
69
|
+
// unrelated request, so restore constructor defaults exactly like
|
|
70
|
+
// ChatSession.reset() instead of leaking the prior committed overlay.
|
|
71
|
+
internals.activeTools = internals.defaultConfig?.tools;
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** POST /v1/messages/count_tokens — Anthropic Messages token-count endpoint. */
|
|
2
|
+
import type { ServerResponse } from 'node:http';
|
|
3
|
+
import type { IdleSweeper } from '../idle-sweeper.js';
|
|
4
|
+
import type { ModelWorkCoordinator } from '../model-work-coordinator.js';
|
|
5
|
+
import type { ModelRegistry } from '../registry.js';
|
|
6
|
+
import type { AnthropicCountTokensRequest } from '../types-anthropic.js';
|
|
7
|
+
export declare function handleCountMessageTokens(res: ServerResponse, body: AnthropicCountTokensRequest, registry: ModelRegistry, idleSweeper?: IdleSweeper | null, resolveModel?: (name: string) => Promise<void>, modelWorkCoordinator?: ModelWorkCoordinator): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=messages-count-tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages-count-tokens.d.ts","sourceRoot":"","sources":["../../src/endpoints/messages-count-tokens.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAUhD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AACnE,OAAO,KAAK,EAAE,2BAA2B,EAAgC,MAAM,uBAAuB,CAAC;AAqBvG,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,2BAA2B,EACjC,QAAQ,EAAE,aAAa,EACvB,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,EAChC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,EAC9C,oBAAoB,CAAC,EAAE,oBAAoB,GAC1C,OAAO,CAAC,IAAI,CAAC,CAuHf"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** POST /v1/messages/count_tokens — Anthropic Messages token-count endpoint. */
|
|
2
|
+
import { sendAnthropicBadRequest, sendAnthropicInternalError, sendAnthropicNotFound, sendAnthropicNotImplemented, } from '../errors.js';
|
|
3
|
+
import { mapAnthropicRequest } from '../mappers/anthropic-request.js';
|
|
4
|
+
function getChatTemplateTokenCounter(model) {
|
|
5
|
+
const candidate = model;
|
|
6
|
+
return typeof candidate.applyChatTemplate === 'function' ? candidate : null;
|
|
7
|
+
}
|
|
8
|
+
function endJson(res, body) {
|
|
9
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
10
|
+
res.end(JSON.stringify(body));
|
|
11
|
+
}
|
|
12
|
+
export async function handleCountMessageTokens(res, body, registry, idleSweeper, resolveModel, modelWorkCoordinator) {
|
|
13
|
+
if (body == null || typeof body !== 'object') {
|
|
14
|
+
sendAnthropicBadRequest(res, 'Request body must be a JSON object');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!body.model) {
|
|
18
|
+
sendAnthropicBadRequest(res, 'Missing required field: model');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
|
22
|
+
sendAnthropicBadRequest(res, 'Missing required field: messages');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const msg of body.messages) {
|
|
26
|
+
if (msg == null || typeof msg !== 'object') {
|
|
27
|
+
sendAnthropicBadRequest(res, 'Each message must be a non-null object');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let mapped;
|
|
32
|
+
try {
|
|
33
|
+
mapped = mapAnthropicRequest(body);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (resolveModel) {
|
|
40
|
+
try {
|
|
41
|
+
const runResolve = () => idleSweeper ? idleSweeper.withSuspendedDrains(() => resolveModel(body.model)) : resolveModel(body.model);
|
|
42
|
+
if (modelWorkCoordinator)
|
|
43
|
+
await modelWorkCoordinator.withModelLoad(runResolve);
|
|
44
|
+
else
|
|
45
|
+
await runResolve();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const lease = registry.acquireDispatchLease(body.model);
|
|
53
|
+
if (!lease) {
|
|
54
|
+
if (registry.get(body.model) != null) {
|
|
55
|
+
sendAnthropicInternalError(res, 'session registry missing for registered model');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
sendAnthropicNotFound(res, `Model "${body.model}" not found`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const leaseModel = lease.model;
|
|
62
|
+
const sessionReg = lease.registry;
|
|
63
|
+
const preLockInstanceId = lease.instanceId;
|
|
64
|
+
try {
|
|
65
|
+
// Token counting is a pure-CPU tokenize-and-template operation; it must NOT
|
|
66
|
+
// queue behind the per-model generation FIFO (`sessionReg.withExclusive`)
|
|
67
|
+
// because that serializes against multi-minute decode passes and turns a
|
|
68
|
+
// millisecond call into a multi-hundred-second wait. The dispatch lease
|
|
69
|
+
// already pins the model object + binding for the duration of this call,
|
|
70
|
+
// and `withInference` (a shared reader lock against `withModelLoad`) is
|
|
71
|
+
// enough to keep the model from being swapped out mid-tokenize.
|
|
72
|
+
const bindingStillMatchesLease = () => {
|
|
73
|
+
const lockedSessionReg = registry.getSessionRegistry(body.model);
|
|
74
|
+
const lockedInstanceId = registry.getInstanceId(body.model);
|
|
75
|
+
return (lockedSessionReg !== undefined &&
|
|
76
|
+
lockedInstanceId !== undefined &&
|
|
77
|
+
lockedSessionReg === sessionReg &&
|
|
78
|
+
lockedInstanceId === preLockInstanceId);
|
|
79
|
+
};
|
|
80
|
+
const rejectChangedBinding = (phase) => {
|
|
81
|
+
sendAnthropicBadRequest(res, `Model "${body.model}" binding changed while the token-count request was ${phase}. ` +
|
|
82
|
+
`A concurrent register() re-pointed the name at a different model instance ` +
|
|
83
|
+
`(or released it entirely), so counting against the leased model would use ` +
|
|
84
|
+
`a stale model object. Retry the request — if the swap was intentional, the ` +
|
|
85
|
+
`new binding will service the retry cleanly.`);
|
|
86
|
+
};
|
|
87
|
+
const runCountWithModelRead = async () => {
|
|
88
|
+
if (!bindingStillMatchesLease()) {
|
|
89
|
+
rejectChangedBinding('waiting for the model-load reader gate');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const counter = getChatTemplateTokenCounter(leaseModel);
|
|
93
|
+
if (!counter) {
|
|
94
|
+
sendAnthropicNotImplemented(res, `Model "${body.model}" does not expose applyChatTemplate(); token counting requires a ` +
|
|
95
|
+
`non-generating chat-template tokenizer API on the registered model.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const tokens = await counter.applyChatTemplate(mapped.messages, true, mapped.config.tools ?? null);
|
|
100
|
+
if (!bindingStillMatchesLease()) {
|
|
101
|
+
rejectChangedBinding('running');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
endJson(res, { input_tokens: tokens.length });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to count tokens');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (modelWorkCoordinator)
|
|
111
|
+
await modelWorkCoordinator.withInference(runCountWithModelRead);
|
|
112
|
+
else
|
|
113
|
+
await runCountWithModelRead();
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to count tokens');
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
registry.releaseDispatchLease(leaseModel);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,13 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /v1/messages — stateless Anthropic Messages API.
|
|
3
3
|
*
|
|
4
|
-
* Every request carries the full conversation in `req.messages`.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Every request carries the full conversation in `req.messages`. The
|
|
5
|
+
* Anthropic Messages API is stateless on the wire: there is no
|
|
6
|
+
* `previous_response_id` to thread, and clients (e.g. Claude Code)
|
|
7
|
+
* also do NOT propagate `prompt_cache_key` back to the server. The
|
|
8
|
+
* cross-turn / cross-conversation prefix-reuse path is one of two
|
|
9
|
+
* mutually-exclusive mechanisms, picked at request time based on
|
|
10
|
+
* whether the underlying native model has the block-paged KV cache
|
|
11
|
+
* adapter active (`SessionCapableModel.hasBlockPagedCache?.()`):
|
|
12
|
+
*
|
|
13
|
+
* * **Paged-active path** (Qwen3 + LFM2 + Gemma4 are paged-active
|
|
14
|
+
* today; Qwen3.5 dense/MoE and Qianfan-OCR remain non-paged /
|
|
15
|
+
* default-off pending a perf decision and adapter wiring
|
|
16
|
+
* respectively). Each request allocates a fresh `ChatSession` via
|
|
17
|
+
* `SessionRegistry.createFreshSession()` and runs a full
|
|
18
|
+
* `session.reset()` + `primeHistory()` +
|
|
19
|
+
* `startFromHistory[Stream]()`. The JS-side warm slot is
|
|
20
|
+
* **not** consulted, **not** leased, and **not** adopted —
|
|
21
|
+
* cross-request prefix reuse is handled entirely by the native
|
|
22
|
+
* `BlockAllocator`'s content-addressed prefix-hash table, which
|
|
23
|
+
* refcounts SYS blocks shared across requests transparently
|
|
24
|
+
* (two parallel `/v1/messages` requests with the same system
|
|
25
|
+
* prompt run on distinct `ChatSession` objects but reference
|
|
26
|
+
* the same physical KV blocks). The non-streaming
|
|
27
|
+
* `X-Session-Cache` header is promoted from `fresh` to
|
|
28
|
+
* `prefix_hit` after dispatch when the engine reports
|
|
29
|
+
* `cachedTokens > 0`.
|
|
30
|
+
*
|
|
31
|
+
* * **Non-paged path** (Qwen3.5 dense + MoE — default-off pending a
|
|
32
|
+
* perf decision; the Qianfan-OCR VLM — no adapter wired). Each
|
|
33
|
+
* request looks up the warm slot via
|
|
34
|
+
* `SessionRegistry.getOrCreateWarmAny(requestedSystem)`. On a
|
|
35
|
+
* HIT we keep the underlying native KV cache alive
|
|
36
|
+
* (`resetPreservingNativeCacheForWarmReuse` wipes only JS-side
|
|
37
|
+
* session state) so the native `verify_cache_prefix_direct` can
|
|
38
|
+
* recognize the cached prefix and re-prefill only the new
|
|
39
|
+
* suffix. On a MISS we run a full `session.reset()` to wipe
|
|
40
|
+
* both JS and native state — a fresh JS session does NOT imply
|
|
41
|
+
* a fresh native cache (the underlying `SessionCapableModel` is
|
|
42
|
+
* shared and its native `cached_token_history` persists across
|
|
43
|
+
* requests). After the dispatch settles we adopt the session
|
|
44
|
+
* back under the sentinel id `'__msg_warm__'` (or drop on
|
|
45
|
+
* uncommitted streams / thrown errors) so the next turn can
|
|
46
|
+
* lease it. The sentinel is never produced by either the OpenAI
|
|
47
|
+
* or the Anthropic wire format, so cross-endpoint capture via
|
|
48
|
+
* tier-1 is impossible by construction. The `/v1/responses` and
|
|
49
|
+
* `/v1/messages` endpoints still SHARE the single warm slot
|
|
50
|
+
* under the registry's single-warm invariant on this path — a
|
|
51
|
+
* turn on one side can evict the other's slot.
|
|
52
|
+
*
|
|
53
|
+
* The `prompt_cache_key` request field is still NOT exposed on this
|
|
54
|
+
* endpoint. Cross-conversation block-level cache reuse on the
|
|
55
|
+
* paged path is now driven by native content-addressing instead of
|
|
56
|
+
* the JS warm slot, so adding the field is no longer a prerequisite
|
|
57
|
+
* for that use case.
|
|
8
58
|
*/
|
|
9
59
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
60
|
+
import type { IdleSweeper } from '../idle-sweeper.js';
|
|
61
|
+
import type { ModelWorkCoordinator } from '../model-work-coordinator.js';
|
|
10
62
|
import type { ModelRegistry } from '../registry.js';
|
|
11
63
|
import type { AnthropicMessagesRequest } from '../types-anthropic.js';
|
|
12
|
-
export declare function handleCreateMessage(res: ServerResponse, body: AnthropicMessagesRequest, registry: ModelRegistry, httpReq?: IncomingMessage): Promise<void>;
|
|
64
|
+
export declare function handleCreateMessage(res: ServerResponse, body: AnthropicMessagesRequest, registry: ModelRegistry, httpReq?: IncomingMessage, idleSweeper?: IdleSweeper | null, resolveModel?: (name: string) => Promise<void>, modelWorkCoordinator?: ModelWorkCoordinator): Promise<void>;
|
|
13
65
|
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/endpoints/messages.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/endpoints/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAajE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAgBtD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAepD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAg3BtE,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,wBAAwB,EAC9B,QAAQ,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,eAAe,EACzB,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,EAChC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,EAC9C,oBAAoB,CAAC,EAAE,oBAAoB,GAC1C,OAAO,CAAC,IAAI,CAAC,CAioBf"}
|