@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.
Files changed (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. package/src/types.ts +470 -0
@@ -1,5 +1,5 @@
1
1
  /** Discover locally-downloaded generative models under a given directory. */
2
- import { type LaunchPreset, type ModelType } from '@mlx-node/lm';
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
- * Scan `dir` for model subdirectories. Each subdirectory with a recognized
12
- * `config.json` is returned with its inferred `ModelType` and `LaunchPreset`.
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;AAM7E,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAEtB,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;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAoC5E"}
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"}
@@ -1,47 +1,14 @@
1
1
  /** Discover locally-downloaded generative models under a given directory. */
2
- import { readdir } from 'node:fs/promises';
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
- * Scan `dir` for model subdirectories. Each subdirectory with a recognized
7
- * `config.json` is returned with its inferred `ModelType` and `LaunchPreset`.
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
- const debug = Boolean(process.env.MLX_DEBUG);
14
- let entries;
15
- try {
16
- entries = await readdir(dir, { withFileTypes: true });
17
- }
18
- catch {
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
  }
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"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"}
@@ -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) => loadModelFn(await pagedConfigOverrides.resolve(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.13",
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.13",
43
- "@mlx-node/lm": "0.0.13"
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
+ }