@mlx-node/server 0.0.12 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
|
@@ -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
|
+
}
|