@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.
Files changed (110) hide show
  1. package/dist/auth.d.ts +56 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +106 -0
  4. package/dist/chat-session-warm-reuse.d.ts +51 -0
  5. package/dist/chat-session-warm-reuse.d.ts.map +1 -0
  6. package/dist/chat-session-warm-reuse.js +72 -0
  7. package/dist/endpoints/messages-count-tokens.d.ts +8 -0
  8. package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
  9. package/dist/endpoints/messages-count-tokens.js +121 -0
  10. package/dist/endpoints/messages.d.ts +57 -5
  11. package/dist/endpoints/messages.d.ts.map +1 -1
  12. package/dist/endpoints/messages.js +1043 -147
  13. package/dist/endpoints/models.d.ts +2 -1
  14. package/dist/endpoints/models.d.ts.map +1 -1
  15. package/dist/endpoints/models.js +2 -2
  16. package/dist/endpoints/responses.d.ts +22 -7
  17. package/dist/endpoints/responses.d.ts.map +1 -1
  18. package/dist/endpoints/responses.js +608 -85
  19. package/dist/errors.d.ts +1 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -0
  22. package/dist/handler.d.ts +69 -1
  23. package/dist/handler.d.ts.map +1 -1
  24. package/dist/handler.js +70 -16
  25. package/dist/health.d.ts +146 -0
  26. package/dist/health.d.ts.map +1 -0
  27. package/dist/health.js +107 -0
  28. package/dist/host/discover.d.ts +19 -0
  29. package/dist/host/discover.d.ts.map +1 -0
  30. package/dist/host/discover.js +50 -0
  31. package/dist/host/env-policy.d.ts +62 -0
  32. package/dist/host/env-policy.d.ts.map +1 -0
  33. package/dist/host/env-policy.js +69 -0
  34. package/dist/host/index.d.ts +202 -0
  35. package/dist/host/index.d.ts.map +1 -0
  36. package/dist/host/index.js +325 -0
  37. package/dist/host/logger.d.ts +36 -0
  38. package/dist/host/logger.d.ts.map +1 -0
  39. package/dist/host/logger.js +376 -0
  40. package/dist/host/net.d.ts +65 -0
  41. package/dist/host/net.d.ts.map +1 -0
  42. package/dist/host/net.js +97 -0
  43. package/dist/host/paths.d.ts +28 -0
  44. package/dist/host/paths.d.ts.map +1 -0
  45. package/dist/host/paths.js +71 -0
  46. package/dist/host/swap.d.ts +27 -0
  47. package/dist/host/swap.d.ts.map +1 -0
  48. package/dist/host/swap.js +178 -0
  49. package/dist/host/temp-root.d.ts +57 -0
  50. package/dist/host/temp-root.d.ts.map +1 -0
  51. package/dist/host/temp-root.js +99 -0
  52. package/dist/idle-sweeper.d.ts +245 -0
  53. package/dist/idle-sweeper.d.ts.map +1 -0
  54. package/dist/idle-sweeper.js +408 -0
  55. package/dist/index.d.ts +19 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +17 -1
  58. package/dist/load-model.d.ts +69 -0
  59. package/dist/load-model.d.ts.map +1 -0
  60. package/dist/load-model.js +63 -0
  61. package/dist/mappers/anthropic-request.d.ts +24 -2
  62. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  63. package/dist/mappers/anthropic-request.js +222 -24
  64. package/dist/mappers/anthropic-response.d.ts +29 -4
  65. package/dist/mappers/anthropic-response.d.ts.map +1 -1
  66. package/dist/mappers/anthropic-response.js +143 -21
  67. package/dist/mappers/request.d.ts +48 -0
  68. package/dist/mappers/request.d.ts.map +1 -1
  69. package/dist/mappers/request.js +211 -35
  70. package/dist/mappers/response.d.ts.map +1 -1
  71. package/dist/mappers/response.js +13 -1
  72. package/dist/model-work-coordinator.d.ts +95 -0
  73. package/dist/model-work-coordinator.d.ts.map +1 -0
  74. package/dist/model-work-coordinator.js +201 -0
  75. package/dist/pending-writes.d.ts.map +1 -1
  76. package/dist/presets.d.ts +82 -0
  77. package/dist/presets.d.ts.map +1 -0
  78. package/dist/presets.js +98 -0
  79. package/dist/registry.d.ts +31 -1
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +33 -5
  82. package/dist/router.d.ts +37 -1
  83. package/dist/router.d.ts.map +1 -1
  84. package/dist/router.js +79 -7
  85. package/dist/server.d.ts +193 -3
  86. package/dist/server.d.ts.map +1 -1
  87. package/dist/server.js +173 -10
  88. package/dist/session-registry.d.ts +279 -18
  89. package/dist/session-registry.d.ts.map +1 -1
  90. package/dist/session-registry.js +518 -37
  91. package/dist/stop-sequence-buffer.d.ts +58 -0
  92. package/dist/stop-sequence-buffer.d.ts.map +1 -0
  93. package/dist/stop-sequence-buffer.js +148 -0
  94. package/dist/streaming.d.ts +14 -0
  95. package/dist/streaming.d.ts.map +1 -1
  96. package/dist/streaming.js +45 -0
  97. package/dist/text-recovery.d.ts +35 -0
  98. package/dist/text-recovery.d.ts.map +1 -0
  99. package/dist/text-recovery.js +41 -0
  100. package/dist/timing.d.ts +80 -0
  101. package/dist/timing.d.ts.map +1 -0
  102. package/dist/timing.js +121 -0
  103. package/dist/tool-call-buffer.d.ts +5 -5
  104. package/dist/tool-call-buffer.d.ts.map +1 -1
  105. package/dist/tool-call-buffer.js +28 -8
  106. package/dist/types-anthropic.d.ts +161 -1
  107. package/dist/types-anthropic.d.ts.map +1 -1
  108. package/dist/types.d.ts +172 -2
  109. package/dist/types.d.ts.map +1 -1
  110. 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`. 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.
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;;;;;;;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"}
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"}