@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
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Safe out-of-band model load.
3
+ *
4
+ * A supervisor (or a `/model`-picker UI) needs to swap the resident model on a
5
+ * server that is already serving. Doing that naively races the Metal
6
+ * allocator — a corruption/abort bug, not a latency bug — so the bracketing
7
+ * order below is load-bearing:
8
+ *
9
+ * ```text
10
+ * idleSweeper.withSuspendedDrains( ← OUTSIDE
11
+ * modelWorkCoordinator.withModelLoad( ← INSIDE
12
+ * load() ; registry.register(...)
13
+ * , label))
14
+ * ```
15
+ *
16
+ * Why the suspension must be OUTSIDE the writer lock: `endRequest()` arms a
17
+ * drain timer at `t + idleClearCacheMs` whose callback runs
18
+ * `__internal__.clearCache()`, walking the process-wide MLX free pool. A load
19
+ * that arrives while another load holds the writer slot parks inside
20
+ * `acquireWrite()` — and if the suspension were taken only after the lock was
21
+ * won, that armed timer would still be live during the wait AND during the
22
+ * hand-off, firing mid-materialization. Suspending first covers the wait, the
23
+ * lock hand-off, `load()`, and `register()` as one interval.
24
+ *
25
+ * Why the writer lock must be INSIDE: `withModelLoad` takes the exclusive
26
+ * writer slot, which excludes inference readers for exactly as long as
27
+ * weights are being materialized — no longer. Hoisting it outside the
28
+ * suspension would gain nothing and would hold readers off during the drain
29
+ * bookkeeping too.
30
+ *
31
+ * See `ServerInstance.withSuspendedDrains` and the `__internal__.clearCache`
32
+ * rustdoc in `packages/core/index.d.cts` for the underlying contracts.
33
+ */
34
+
35
+ import type { ChatConfig } from '@mlx-node/core';
36
+
37
+ import type { IdleSweeper } from './idle-sweeper.js';
38
+ import type { ModelWorkCoordinator } from './model-work-coordinator.js';
39
+ import type { ModelRegistry, RegisterOptions, ServableModel } from './registry.js';
40
+
41
+ export interface GuardedLoadDeps {
42
+ /** Only `withSuspendedDrains` is used; typed narrowly so tests can double it. */
43
+ idleSweeper: Pick<IdleSweeper, 'withSuspendedDrains'>;
44
+ modelWorkCoordinator: ModelWorkCoordinator;
45
+ registry: ModelRegistry;
46
+ }
47
+
48
+ export interface LoadModelOptions {
49
+ /** Primary registration name. Also used as the `/health` load label. */
50
+ name: string;
51
+ /** Materializes the model. Invoked exactly once, inside both brackets. */
52
+ load: () => Promise<ServableModel>;
53
+ /**
54
+ * Extra names bound to the SAME instance. Aliases share one
55
+ * `SessionRegistry`, preserving the single-warm invariant across names.
56
+ * An alias equal to `name` is ignored rather than re-registered.
57
+ */
58
+ aliases?: string[];
59
+ /** Per-model sampling defaults; forwarded to every alias too. */
60
+ samplingDefaults?: ChatConfig;
61
+ /** Per-model output-token clamp; forwarded to every alias too. */
62
+ maxOutputTokens?: number;
63
+ }
64
+
65
+ /**
66
+ * Load a model and register it under `name` (plus any aliases) with drains
67
+ * suspended and inference excluded for the whole materialization window.
68
+ *
69
+ * Rejects with the underlying error if `load()` (or `register`) throws;
70
+ * both brackets unwind via their own `finally`, so neither the suspend
71
+ * counter nor the writer lock can leak.
72
+ */
73
+ export async function runGuardedModelLoad(deps: GuardedLoadDeps, opts: LoadModelOptions): Promise<void> {
74
+ // Build the register options once, omitting keys the caller never set.
75
+ // `ModelRegistry.register` uses `'samplingDefaults' in opts` to decide
76
+ // whether to OVERWRITE an existing binding's defaults, so passing an
77
+ // explicit `undefined` would silently clear them on a re-register.
78
+ const registerOpts: RegisterOptions = {};
79
+ if (opts.samplingDefaults !== undefined) registerOpts.samplingDefaults = opts.samplingDefaults;
80
+ if (opts.maxOutputTokens !== undefined) registerOpts.maxOutputTokens = opts.maxOutputTokens;
81
+
82
+ await deps.idleSweeper.withSuspendedDrains(async () => {
83
+ await deps.modelWorkCoordinator.withModelLoad(async () => {
84
+ const instance = await opts.load();
85
+ deps.registry.register(opts.name, instance, registerOpts);
86
+ for (const alias of opts.aliases ?? []) {
87
+ if (alias === opts.name) continue;
88
+ deps.registry.register(alias, instance, registerOpts);
89
+ }
90
+ }, opts.name);
91
+ });
92
+ }
@@ -0,0 +1,485 @@
1
+ /** Anthropic Messages API request → internal `ChatMessage[]` + `ChatConfig`. */
2
+
3
+ import type { ChatConfig, ChatMessage, ToolDefinition } from '@mlx-node/core';
4
+
5
+ import type {
6
+ AnthropicContentBlock,
7
+ AnthropicCountTokensRequest,
8
+ AnthropicImageContentBlock,
9
+ AnthropicMessagesRequest,
10
+ AnthropicTextContentBlock,
11
+ AnthropicToolDefinition,
12
+ SystemBlock,
13
+ } from '../types-anthropic.js';
14
+ import { anthropicToolUseIdToInternal } from './anthropic-response.js';
15
+ import { applyExtraBodyMtpOverrides, validateCacheSalt } from './request.js';
16
+
17
+ export interface MappedAnthropicRequest {
18
+ messages: ChatMessage[];
19
+ config: ChatConfig;
20
+ /**
21
+ * Client-supplied stop strings (Anthropic `stop_sequences`), normalized to
22
+ * drop absent/empty entries. Carried alongside `config` rather than on it
23
+ * because `ChatConfig` has no native stop field; a downstream consumer is
24
+ * responsible for honouring these.
25
+ */
26
+ stopSequences: string[];
27
+ }
28
+
29
+ /**
30
+ * Anthropic billing/attribution header prefix. Claude Code injects a leading
31
+ * system block of the shape `"x-anthropic-billing-header: cc_version=...; cch=<token>;"`
32
+ * where the `cch=` token rotates per request. Leaving this in the prompt
33
+ * defeats prefix caching at BOTH the warm-slot gate (`getOrCreateWarmAny`,
34
+ * which compares `requestedSystem` byte-equally) AND the native
35
+ * token-prefix verifier inside `chatSessionStart`. We mirror vLLM's strategy
36
+ * (`vllm/entrypoints/anthropic/serving.py`, commit 262b76a0, 2026-03-11):
37
+ * stateless, per-block, prefix-only — drop entirely BEFORE tokenization, so
38
+ * the model never sees the rotating token AND the byte-prefix is stable.
39
+ *
40
+ * Hardcoded (not configurable) and intentionally limited to this single
41
+ * prefix to mirror vLLM's exact behaviour. The string-system branch is left
42
+ * UNFILTERED to match upstream.
43
+ */
44
+ const ANTHROPIC_BILLING_HEADER_PREFIX = 'x-anthropic-billing-header';
45
+
46
+ /**
47
+ * Canonicalize the Anthropic `system` field into the same string the mapper
48
+ * bakes into the leading `system` ChatMessage. Used by both
49
+ * `mapAnthropicRequest` (so the model never sees the billing header) and the
50
+ * `/v1/messages` warm-slot gate cache-key derivation (so the gate matches
51
+ * across rotating billing tokens). The two views MUST stay in sync — a
52
+ * single source of truth prevents drift.
53
+ *
54
+ * Asymmetry vs. `mapAnthropicRequest`: the mapper THROWS on non-text blocks
55
+ * (it's a request-validation gate), but this helper silently skips them.
56
+ * Safe because `mapAnthropicRequest` runs first as a pre-flight gate, so by
57
+ * the time the cache-key is computed, the request shape has already been
58
+ * validated.
59
+ */
60
+ export function canonicalizeSystemForCacheKey(system: AnthropicMessagesRequest['system']): string | null {
61
+ if (system == null) return null;
62
+ if (typeof system === 'string') return system;
63
+ const parts: string[] = [];
64
+ for (const b of system) {
65
+ if (b.type === 'text' && !b.text.startsWith(ANTHROPIC_BILLING_HEADER_PREFIX)) {
66
+ parts.push(b.text);
67
+ }
68
+ }
69
+ // An array whose every block is stripped (e.g. a request whose only system
70
+ // block is the rotating `x-anthropic-billing-header` line) is semantically
71
+ // equivalent to "no system" — collapse to `null` so it (a) does NOT push an
72
+ // empty `system` ChatMessage that the chat template wraps with two extra
73
+ // `<|im_start|>system\n<|im_end|>\n` tokens (perturbing the prefix vs. an
74
+ // absent-system request), and (b) compares byte-equal to `undefined` /
75
+ // missing on the warm-slot gate (`SessionRegistry.getOrCreateWarmAny`
76
+ // checks `entry.instructions !== requestedInstructions`, where `null !==
77
+ // ''` would otherwise miss the slot).
78
+ if (parts.length === 0) return null;
79
+ return parts.join('');
80
+ }
81
+
82
+ /**
83
+ * Resolve the text content of a `tool_result` block. The internal `ChatMessage`
84
+ * shape (NAPI-generated) has no `images` field on `role: 'tool'`, so nested
85
+ * images are rejected outright — any hoist-to-trailing-user workaround loses
86
+ * both declared order and per-tool association once downstream canonicalization
87
+ * reorders the tool rows. Callers must send images as a top-level image block
88
+ * in a separate user turn.
89
+ */
90
+ function resolveToolResultContent(content?: string | (AnthropicTextContentBlock | AnthropicImageContentBlock)[]): {
91
+ text: string;
92
+ } {
93
+ if (content == null) return { text: '' };
94
+ if (typeof content === 'string') return { text: content };
95
+ const parts: string[] = [];
96
+ for (const b of content) {
97
+ if (b.type === 'text') {
98
+ parts.push(b.text);
99
+ } else if (b.type === 'image') {
100
+ throw new Error(
101
+ 'Unsupported: nested image content in tool_result blocks is not representable in the internal ' +
102
+ 'message model. Send the image as a top-level image block in a separate user turn, and reference ' +
103
+ 'it from the tool_result via text.',
104
+ );
105
+ } else {
106
+ throw new Error(`Unsupported tool_result content type: "${(b as { type: string }).type}"`);
107
+ }
108
+ }
109
+ return { text: parts.join('') };
110
+ }
111
+
112
+ /** NAPI `ToolDefinition` requires `parameters.properties` to be a JSON string. */
113
+ function mapTool(tool: AnthropicToolDefinition): ToolDefinition {
114
+ const schema = tool.input_schema;
115
+ return {
116
+ type: 'function',
117
+ function: {
118
+ name: tool.name,
119
+ description: tool.description,
120
+ parameters: {
121
+ type: typeof schema['type'] === 'string' ? schema['type'] : 'object',
122
+ properties: JSON.stringify(schema['properties'] ?? {}),
123
+ required: Array.isArray(schema['required']) ? (schema['required'] as string[]) : undefined,
124
+ },
125
+ },
126
+ };
127
+ }
128
+
129
+ export function mapAnthropicRequest(
130
+ req: AnthropicMessagesRequest | AnthropicCountTokensRequest,
131
+ ): MappedAnthropicRequest {
132
+ const messages: ChatMessage[] = [];
133
+
134
+ // The leading system message is assembled and `unshift`ed AFTER the message
135
+ // loop so that any `system`-role message folded out of `req.messages` (see
136
+ // the `role === 'system'` branch below) is concatenated with the top-level
137
+ // `system` field into a single leading system prompt. We compute the
138
+ // top-level contribution here but defer the push.
139
+ let topLevelSystem: string | null = null;
140
+ if (req.system != null) {
141
+ if (typeof req.system === 'string') {
142
+ topLevelSystem = req.system;
143
+ } else {
144
+ // Validate first — throw early on unsupported block types so the
145
+ // request fails fast (this is the validation gate). Stripping of
146
+ // the rotating billing-header prefix happens inside
147
+ // `canonicalizeSystemForCacheKey`, the single source of truth
148
+ // shared with `endpoints/messages.ts`'s `requestedSystem` cache
149
+ // key. Routing both call sites through the same helper means
150
+ // any future change to the strip semantics (prefix list,
151
+ // normalization, etc.) lands in exactly one place — the mapped
152
+ // messages and the cache-key view cannot drift.
153
+ for (const b of req.system as SystemBlock[]) {
154
+ if (b.type !== 'text') {
155
+ throw new Error(`Unsupported system block type: "${(b as { type: string }).type}"`);
156
+ }
157
+ }
158
+ // Helper returns `null` when every block was stripped (e.g. a
159
+ // request whose only system block is the rotating
160
+ // `x-anthropic-billing-header` line). An all-stripped array is
161
+ // semantically equivalent to "no system", so it contributes
162
+ // nothing — emitting `{ role: 'system', content: '' }` would
163
+ // otherwise have the chat template wrap it with two extra
164
+ // `<|im_start|>system\n<|im_end|>\n` tokens, perturbing the
165
+ // prefix vs. an absent-system request and breaking prefix
166
+ // caching across the two semantically-equivalent shapes.
167
+ topLevelSystem = canonicalizeSystemForCacheKey(req.system);
168
+ }
169
+ }
170
+
171
+ // Text folded out of any `system`-role message(s) in `req.messages`, in
172
+ // encounter order. Anthropic has no system role in `messages`, but Claude
173
+ // Code hooks inject one; its content is positionless "additional context"
174
+ // so we fold it into the leading system prompt regardless of position.
175
+ const foldedSystemParts: string[] = [];
176
+
177
+ for (const msg of req.messages) {
178
+ const { role, content } = msg;
179
+
180
+ if (role === 'user') {
181
+ if (typeof content === 'string') {
182
+ messages.push({ role: 'user', content });
183
+ } else {
184
+ // An Anthropic user turn may carry either pure text/image blocks,
185
+ // or a contiguous prefix of `tool_result` blocks optionally followed
186
+ // by trailing text/image blocks. Interleaving text/image BEFORE a
187
+ // tool_result is rejected — we cannot preserve author intent and
188
+ // fan-out ordering without silently reordering the caller's blocks.
189
+ // Caller-relative order within a tool_result prefix is preserved;
190
+ // `validateAndCanonicalizeHistoryToolOrder` reorders later if needed.
191
+ const toolResults: {
192
+ toolCallId: string;
193
+ content: string;
194
+ isError: boolean;
195
+ }[] = [];
196
+ const trailingText: string[] = [];
197
+ const trailingImages: Uint8Array[] = [];
198
+ let seenNonToolResult = false;
199
+ let seenToolResult = false;
200
+ // The flat `ChatMessage` shape cannot represent a text block that
201
+ // appears AFTER an image block in the same turn — the downstream
202
+ // Jinja serializer always places text before images. Reject that
203
+ // interleaving up front rather than silently reordering it and
204
+ // changing the caller's intent. This parallels the identical
205
+ // guard in `request.ts:resolveMessageContent` for the
206
+ // `/v1/responses` mapper.
207
+ let seenImage = false;
208
+
209
+ for (const block of content as AnthropicContentBlock[]) {
210
+ if (block.type === 'tool_result') {
211
+ if (seenNonToolResult) {
212
+ throw new Error(
213
+ 'Unsupported: tool_result blocks must appear as a contiguous prefix of the user ' +
214
+ 'turn, before any text or image blocks. Interleaving a text/image block before a ' +
215
+ 'tool_result would require reordering the caller-supplied blocks and silently ' +
216
+ 'changing authorship.',
217
+ );
218
+ }
219
+ seenToolResult = true;
220
+ const resolved = resolveToolResultContent(block.content);
221
+ // Anthropic clients echo back the same `toolu_<uuid>` we
222
+ // emitted on the prior assistant turn. Translate it back to
223
+ // the internal `call_<uuid>` shape so the native session
224
+ // store's tool_call_id lookup (which sees the original
225
+ // `call_*` id) still matches. Ids that lack the `toolu_`
226
+ // prefix (legacy callers that already speak the internal
227
+ // shape) pass through unchanged.
228
+ toolResults.push({
229
+ toolCallId: anthropicToolUseIdToInternal(block.tool_use_id),
230
+ content: resolved.text,
231
+ isError: block.is_error === true,
232
+ });
233
+ } else if (block.type === 'text') {
234
+ if (seenImage) {
235
+ throw new Error(
236
+ 'Unsupported: text block after an image block in the same user turn is not representable ' +
237
+ 'in the internal message model. The flat ChatMessage shape and the Jinja serializer both ' +
238
+ 'place all text before all images, so any mapping would silently reorder your content. ' +
239
+ 'Place all text blocks before any image blocks, or split across separate user turns.',
240
+ );
241
+ }
242
+ seenNonToolResult = true;
243
+ trailingText.push(block.text);
244
+ } else if (block.type === 'image' && block.source.type === 'base64') {
245
+ seenNonToolResult = true;
246
+ seenImage = true;
247
+ trailingImages.push(Buffer.from(block.source.data, 'base64'));
248
+ } else {
249
+ throw new Error(`Unsupported content block type: "${block.type}"`);
250
+ }
251
+ }
252
+
253
+ if (seenToolResult) {
254
+ // The structured `isError` field on the internal `ChatMessage`
255
+ // is the authoritative signal of tool-call failure (mirroring
256
+ // the existing `toolCallId` pattern). Pass `tr.content`
257
+ // through verbatim — no JSON envelope, no in-band marker — and
258
+ // surface the error condition via the dedicated structured
259
+ // field. The Rust-side wire-format renderers (Jinja serializer
260
+ // for the cold-start path, ChatML formatter for the fallback
261
+ // template) inject a short model-facing `[tool error]` cue
262
+ // into the prompt when `isError === true`, but the
263
+ // `ChatMessage.content` itself stays byte-for-byte equal to
264
+ // the original payload so a successful tool result whose
265
+ // content happens to start with the same marker text cannot
266
+ // be confused with an errored one on read-back. Neither
267
+ // mlx-lm nor mlx-vlm have a precedent for an in-band marker
268
+ // here; the structured-field approach matches how
269
+ // `toolCallId` is plumbed and survives round-tripping cleanly.
270
+ for (const tr of toolResults) {
271
+ const msg: ChatMessage = {
272
+ role: 'tool',
273
+ content: tr.content,
274
+ toolCallId: tr.toolCallId,
275
+ };
276
+ if (tr.isError) {
277
+ msg.isError = true;
278
+ }
279
+ messages.push(msg);
280
+ }
281
+ // Trailing suffix after a tool_result prefix: accept either
282
+ // (a) text-only (concatenated) or (b) exactly one image block.
283
+ // Mixing text+image or multiple images would silently reorder
284
+ // content in the flat NAPI `ChatMessage` shape.
285
+ const hasTrailingText = trailingText.length > 0;
286
+ const hasTrailingImages = trailingImages.length > 0;
287
+ if (hasTrailingText && hasTrailingImages) {
288
+ throw new Error(
289
+ 'Unsupported: mixing trailing text and image blocks after a tool_result prefix is not ' +
290
+ 'representable in the internal message model. The flat ChatMessage shape cannot preserve ' +
291
+ 'the caller-declared relative order of interleaved text and images, so any mapping would ' +
292
+ 'silently reorder your content. Send any commentary as part of the tool_result text, and ' +
293
+ 'deliver additional images in a separate follow-up user turn.',
294
+ );
295
+ }
296
+ if (hasTrailingImages && trailingImages.length > 1) {
297
+ throw new Error(
298
+ 'Unsupported: multiple trailing image blocks after a tool_result prefix are not ' +
299
+ 'representable in the internal message model without silently reordering the images ' +
300
+ 'relative to any surrounding text. Send at most one trailing image block, and deliver ' +
301
+ 'additional images in a separate follow-up user turn.',
302
+ );
303
+ }
304
+ if (hasTrailingText || hasTrailingImages) {
305
+ const trailingMsg: ChatMessage = { role: 'user', content: trailingText.join('') };
306
+ if (hasTrailingImages) {
307
+ trailingMsg.images = trailingImages;
308
+ }
309
+ messages.push(trailingMsg);
310
+ }
311
+ } else {
312
+ // Pure text/image user turn — always emit exactly one `user` message, even if empty.
313
+ const userMsg: ChatMessage = { role: 'user', content: trailingText.join('') };
314
+ if (trailingImages.length > 0) {
315
+ userMsg.images = trailingImages;
316
+ }
317
+ messages.push(userMsg);
318
+ }
319
+ }
320
+ } else if (role === 'assistant') {
321
+ if (typeof content === 'string') {
322
+ messages.push({ role: 'assistant', content });
323
+ } else {
324
+ // Collapse into a single assistant message. The internal shape does not
325
+ // support text-after-tool_use ordering, so interleaved shapes are rejected.
326
+ let text = '';
327
+ let reasoningContent: string | undefined;
328
+ const toolCalls: { id: string; name: string; arguments: string }[] = [];
329
+ let seenToolUse = false;
330
+
331
+ for (const block of content as AnthropicContentBlock[]) {
332
+ if (block.type === 'text') {
333
+ if (seenToolUse) {
334
+ throw new Error('Text blocks after tool_use blocks are not supported in assistant messages');
335
+ }
336
+ text += block.text;
337
+ } else if (block.type === 'thinking') {
338
+ reasoningContent = (reasoningContent ?? '') + block.thinking;
339
+ } else if (block.type === 'tool_use') {
340
+ seenToolUse = true;
341
+ // Anthropic clients echo back the `toolu_<uuid>` we emitted
342
+ // on the prior assistant turn. Translate to the internal
343
+ // `call_<uuid>` shape so the native session store and the
344
+ // Qwen chat template paths see a consistent id family.
345
+ toolCalls.push({
346
+ id: anthropicToolUseIdToInternal(block.id),
347
+ name: block.name,
348
+ arguments: JSON.stringify(block.input),
349
+ });
350
+ } else {
351
+ throw new Error(`Unsupported assistant content block type: "${block.type}"`);
352
+ }
353
+ }
354
+
355
+ const assistantMsg: ChatMessage = { role: 'assistant', content: text };
356
+ if (reasoningContent != null) {
357
+ assistantMsg.reasoningContent = reasoningContent;
358
+ }
359
+ if (toolCalls.length > 0) {
360
+ assistantMsg.toolCalls = toolCalls;
361
+ }
362
+ messages.push(assistantMsg);
363
+ }
364
+ } else if (role === 'system') {
365
+ // `system` is not a role in the Anthropic Messages spec, but Claude
366
+ // Code's SessionStart hooks (e.g. superpowers) inject a
367
+ // `{ role: 'system' }` message carrying "additional context" into the
368
+ // `messages` array. Rather than rejecting the request (HTTP 400), fold
369
+ // its text into the leading system prompt (assembled after the loop).
370
+ //
371
+ // CONTRACT — position-agnostic hoist (deliberate): a `system`-role
372
+ // message is folded to the SINGLE leading system prompt regardless of
373
+ // where it appears in `messages`. This is intentional, not incidental:
374
+ // 1. The Anthropic wire format has no positional `system` role, so
375
+ // any `{ role: 'system' }` here is non-spec tooling injection with
376
+ // no defined positional semantics to preserve.
377
+ // 2. The only known producer (Claude Code SessionStart hooks) emits
378
+ // positionless "additional context" — conceptually system-level,
379
+ // not a turn-point instruction.
380
+ // 3. The internal `ChatMessage`/`primeHistory` pipeline represents
381
+ // only a SINGLE leading system message; a mid-history system turn
382
+ // is not representable, so hoisting is the sole non-rejecting
383
+ // option. Multiple system-role messages accumulate in encounter
384
+ // order (handled at assembly below).
385
+ if (typeof content === 'string') {
386
+ foldedSystemParts.push(content);
387
+ } else {
388
+ let text = '';
389
+ for (const block of content as AnthropicContentBlock[]) {
390
+ if (block.type !== 'text') {
391
+ throw new Error(`Unsupported content block type "${block.type}" in system-role message`);
392
+ }
393
+ text += block.text;
394
+ }
395
+ foldedSystemParts.push(text);
396
+ }
397
+ } else {
398
+ throw new Error(`Unsupported message role: "${role as string}"`);
399
+ }
400
+ }
401
+
402
+ // Assemble the single leading system prompt from the top-level `system`
403
+ // field plus any folded `system`-role message text. Joined with `'\n\n'`
404
+ // between distinct contributions (a single string when there is only one),
405
+ // so a request with ONLY a top-level system — the overwhelmingly common
406
+ // case — stays byte-identical to the pre-folding behaviour.
407
+ //
408
+ // Empty folded contributions are dropped so an empty hook context message
409
+ // ({ role: 'system', content: '' }) neither corrupts a real top-level
410
+ // system prompt with a trailing `'\n\n'` separator nor synthesises a bare
411
+ // empty system message. The top-level `system` field itself is preserved
412
+ // verbatim (an explicit empty string still emits, matching prior behaviour).
413
+ const systemParts: string[] = [];
414
+ if (topLevelSystem !== null) {
415
+ systemParts.push(topLevelSystem);
416
+ }
417
+ for (const part of foldedSystemParts) {
418
+ if (part.length > 0) {
419
+ systemParts.push(part);
420
+ }
421
+ }
422
+ if (systemParts.length > 0) {
423
+ messages.unshift({ role: 'system', content: systemParts.join('\n\n') });
424
+ }
425
+
426
+ const config: ChatConfig = {
427
+ reportPerformance: true,
428
+ };
429
+
430
+ if (req.cache_salt != null) {
431
+ validateCacheSalt(req.cache_salt);
432
+ config.cacheSalt = req.cache_salt;
433
+ }
434
+ if (req.max_tokens != null) {
435
+ config.maxNewTokens = req.max_tokens;
436
+ }
437
+ if (req.temperature != null) {
438
+ config.temperature = req.temperature;
439
+ }
440
+ if (req.top_p != null) {
441
+ config.topP = req.top_p;
442
+ }
443
+ if (req.top_k != null) {
444
+ config.topK = req.top_k;
445
+ }
446
+
447
+ // `stop_sequences` has no `ChatConfig` field to map onto, so it rides out on
448
+ // the widened return instead. Normalize to drop absent/null entries, empty
449
+ // strings (which would match at every position and stop generation
450
+ // immediately), and whitespace-only entries (which would truncate normal
451
+ // output at the first space/newline; the real Anthropic API rejects these
452
+ // with a 400, so making them a no-op is the lowest-risk resolution). A
453
+ // downstream consumer honours the result.
454
+ const stopSequences = (req.stop_sequences ?? []).filter((s) => typeof s === 'string' && s.trim().length > 0);
455
+
456
+ if (req.tools && req.tools.length > 0) {
457
+ const toolChoice = req.tool_choice;
458
+ if (toolChoice?.type === 'tool') {
459
+ // `{type:'tool', name:'X'}` is a HARD constraint: the model MUST call X
460
+ // and only X. If the caller omitted the name, or named a tool that is
461
+ // not in `req.tools`, falling through to the all-tools path would
462
+ // silently violate that contract. Reject up front so the failure mode
463
+ // is loud and the client gets a clear 400.
464
+ if (!toolChoice.name) {
465
+ throw new Error('tool_choice.type is "tool" but no name was provided');
466
+ }
467
+ const matched = req.tools.filter((t) => t.name === toolChoice.name);
468
+ if (matched.length === 0) {
469
+ throw new Error(
470
+ `tool_choice references tool "${toolChoice.name}" which is not present in the request's tools list`,
471
+ );
472
+ }
473
+ config.tools = matched.map(mapTool);
474
+ } else {
475
+ // `tool_choice` is undefined, `{type:'auto'}`, or `{type:'any'}` — all
476
+ // three semantically mean "let the model pick from any tool", so we
477
+ // forward the full tools array.
478
+ config.tools = req.tools.map(mapTool);
479
+ }
480
+ }
481
+
482
+ applyExtraBodyMtpOverrides(config, req.extra_body);
483
+
484
+ return { messages, config, stopSequences };
485
+ }