@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,1802 @@
1
+ /**
2
+ * POST /v1/messages — stateless Anthropic Messages API.
3
+ *
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, cacheSalt)`. 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.
58
+ */
59
+
60
+ import type { IncomingMessage, ServerResponse } from 'node:http';
61
+
62
+ import type { ChatConfig, ChatMessage, ChatResult, PerformanceMetrics } from '@mlx-node/core';
63
+ import { isContextCapacityError } from '@mlx-node/lm';
64
+ import type { ChatSession, ChatStreamEvent, SessionCapableModel } from '@mlx-node/lm';
65
+
66
+ import { resetPreservingNativeCacheForWarmReuse } from '../chat-session-warm-reuse.js';
67
+ import {
68
+ sendAnthropicBadRequest,
69
+ sendAnthropicInternalError,
70
+ sendAnthropicNotFound,
71
+ sendAnthropicRateLimit,
72
+ } from '../errors.js';
73
+ import type { IdleSweeper } from '../idle-sweeper.js';
74
+ import { canonicalizeSystemForCacheKey, mapAnthropicRequest } from '../mappers/anthropic-request.js';
75
+ import {
76
+ buildAnthropicResponse,
77
+ buildContentBlockDelta,
78
+ buildContentBlockStart,
79
+ buildContentBlockStop,
80
+ buildMessageDelta,
81
+ buildMessageStartEvent,
82
+ buildMessageStop,
83
+ containsToolCallMarkup,
84
+ internalToolCallIdToAnthropic,
85
+ recoverSuppressedToolCallText,
86
+ mapStopReason,
87
+ } from '../mappers/anthropic-response.js';
88
+ import { genId } from '../mappers/response.js';
89
+ import {
90
+ type ModelLoadAdmission,
91
+ ModelLoadQueueFullError,
92
+ type ModelWorkCoordinator,
93
+ } from '../model-work-coordinator.js';
94
+ import type { ModelRegistry } from '../registry.js';
95
+ import { QueueFullError, type PreDispatchAdmission, type SessionRegistry } from '../session-registry.js';
96
+ import { StopSequenceBuffer } from '../stop-sequence-buffer.js';
97
+ import {
98
+ awaitDrainOrClose,
99
+ beginSSE,
100
+ endSSE,
101
+ type SSEClientAbortTracker,
102
+ trackSSEClientAbort,
103
+ writeSSEEvent as writeRawSSEEvent,
104
+ } from '../streaming.js';
105
+ import { longestSuffixPrefixOverlap } from '../text-recovery.js';
106
+ import { resolveServerTuningForUsage, type ServerTimingForUsage } from '../timing.js';
107
+ import { ToolCallTagBuffer } from '../tool-call-buffer.js';
108
+ import {
109
+ createVisibility,
110
+ endJson,
111
+ flushTerminalSSE,
112
+ markSSEMode,
113
+ type TransportVisibility,
114
+ writeFallbackErrorSSE,
115
+ } from '../transport-visibility.js';
116
+ import type { AnthropicMessagesRequest } from '../types-anthropic.js';
117
+ import { MAX_OUTPUT_TOKENS, validateAndCanonicalizeHistoryToolOrder } from './responses.js';
118
+
119
+ /**
120
+ * Sentinel response id used to adopt and drop the per-model warm slot
121
+ * for `/v1/messages` reuse. The Anthropic Messages API does not
122
+ * produce a `previous_response_id` clients could echo back, and the
123
+ * OpenAI `/v1/responses` side mints fresh `resp_*` ids — so this
124
+ * literal can never collide with a tier-1 lookup from either
125
+ * endpoint. Centralised here to keep the four call sites
126
+ * (`adopt` on success, `drop` on failure, both for streaming and
127
+ * non-streaming) in lockstep.
128
+ */
129
+ const MESSAGES_WARM_SLOT_ID = '__msg_warm__';
130
+ const CLAUDE_CODE_TITLE_MAX_TOKENS = 128;
131
+
132
+ function withAdmissionControlledInference<T>(
133
+ sessionReg: SessionRegistry,
134
+ modelWorkCoordinator: ModelWorkCoordinator | undefined,
135
+ // Pre-dispatch permit handed off ATOMICALLY as this call's admission
136
+ // (the selected admission lane consumes it instead of charging
137
+ // `queuedCount` a second time). See `beginPreDispatchAdmission`. Placed BEFORE `fn`
138
+ // so call sites keep the trailing-closure layout.
139
+ permit: PreDispatchAdmission | undefined,
140
+ fn: () => Promise<T>,
141
+ ): Promise<T> {
142
+ const run = () => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn());
143
+ return sessionReg.concurrentAdmissionLimit > 1
144
+ ? sessionReg.withAdmission(run, permit)
145
+ : sessionReg.withExclusive(run, permit);
146
+ }
147
+
148
+ function requestAllowsToolUse(body: AnthropicMessagesRequest): boolean {
149
+ return Array.isArray(body.tools) && body.tools.length > 0;
150
+ }
151
+
152
+ function hasSuppressedToolCalls(result: Pick<ChatResult, 'toolCalls'>, body: AnthropicMessagesRequest): boolean {
153
+ return !requestAllowsToolUse(body) && result.toolCalls.some((t) => t.status === 'ok');
154
+ }
155
+
156
+ function applyOutputTokenLimit(config: ChatConfig, limit: number | undefined): ChatConfig {
157
+ if (
158
+ limit == null ||
159
+ !Number.isFinite(limit) ||
160
+ limit <= 0 ||
161
+ config.maxNewTokens == null ||
162
+ config.maxNewTokens <= limit
163
+ ) {
164
+ return config;
165
+ }
166
+ return { ...config, maxNewTokens: Math.floor(limit) };
167
+ }
168
+
169
+ function systemText(system: AnthropicMessagesRequest['system']): string {
170
+ if (system == null) return '';
171
+ if (typeof system === 'string') return system;
172
+ return system
173
+ .filter((block): block is Extract<(typeof system)[number], { type: 'text' }> => block.type === 'text')
174
+ .map((block) => block.text)
175
+ .join('\n');
176
+ }
177
+
178
+ function hasTitleJsonSchema(schema: unknown): boolean {
179
+ if (schema == null || typeof schema !== 'object') return false;
180
+ const obj = schema as {
181
+ type?: unknown;
182
+ properties?: unknown;
183
+ required?: unknown;
184
+ };
185
+ if (obj.type !== 'object') return false;
186
+ if (obj.properties == null || typeof obj.properties !== 'object') return false;
187
+ const properties = obj.properties as Record<string, unknown>;
188
+ const title = properties['title'];
189
+ if (title == null || typeof title !== 'object') return false;
190
+ if ((title as { type?: unknown }).type !== 'string') return false;
191
+ return Array.isArray(obj.required) && obj.required.includes('title');
192
+ }
193
+
194
+ function isClaudeCodeTitleGenerationRequest(body: AnthropicMessagesRequest): boolean {
195
+ if (requestAllowsToolUse(body)) return false;
196
+ const format = body.output_config?.format;
197
+ if (format?.type !== 'json_schema' || !hasTitleJsonSchema(format.schema)) return false;
198
+
199
+ const prompt = systemText(body.system).toLowerCase();
200
+ return (
201
+ prompt.includes('generate a concise') &&
202
+ prompt.includes('title') &&
203
+ prompt.includes('return json') &&
204
+ prompt.includes('"title"')
205
+ );
206
+ }
207
+
208
+ function applyClaudeCodeTitleFastPath(config: ChatConfig, body: AnthropicMessagesRequest): ChatConfig {
209
+ if (!isClaudeCodeTitleGenerationRequest(body)) return config;
210
+ const cappedMax =
211
+ config.maxNewTokens == null
212
+ ? CLAUDE_CODE_TITLE_MAX_TOKENS
213
+ : Math.min(config.maxNewTokens, CLAUDE_CODE_TITLE_MAX_TOKENS);
214
+ return {
215
+ ...config,
216
+ maxNewTokens: cappedMax,
217
+ reasoningEffort: 'none',
218
+ thinkingTokenBudget: 0,
219
+ includeReasoning: false,
220
+ };
221
+ }
222
+
223
+ // Non-streaming path
224
+
225
+ async function handleNonStreaming(
226
+ res: ServerResponse,
227
+ result: ChatResult,
228
+ body: AnthropicMessagesRequest,
229
+ visibility: TransportVisibility,
230
+ stopSequences: string[],
231
+ serverTiming?: ServerTimingForUsage,
232
+ ): Promise<void> {
233
+ const messageId = genId('msg_');
234
+
235
+ // Honor client-supplied `stop_sequences`: scan the SAME visible text the
236
+ // response builder will emit for the earliest configured stop string. When
237
+ // the request disallows tools but the parser still produced a tool call and
238
+ // `result.text` is empty, `buildAnthropicContent` emits the recovered
239
+ // suppressed-tool text — so the scan must mirror that recovery gate and run
240
+ // over the recovered text, not the empty `result.text`. The scan does
241
+ // push+flush so a complete stop that `push()` held back (a longer
242
+ // overlapping stop was still viable) is resolved at end-of-text, matching
243
+ // the streaming done-path. On a match we truncate the text the response is
244
+ // built from at the match (dropping the stop string and everything after it)
245
+ // and report `stop_reason: 'stop_sequence'` + `stop_sequence: '<matched>'`;
246
+ // `buildAnthropicResponse` then suppresses tool calls and the recovery
247
+ // branch and emits the truncated text verbatim. The native `ChatResult` is
248
+ // left untouched. With no match `responseResult` stays `result` (full text
249
+ // retained — `flush()` releases any held incomplete partial as normal text),
250
+ // so behavior is byte-identical to a request without `stop_sequences`.
251
+ const visibleText =
252
+ !requestAllowsToolUse(body) &&
253
+ result.text.length === 0 &&
254
+ result.toolCalls.filter((t) => t.status === 'ok').length > 0 &&
255
+ containsToolCallMarkup(result.rawText)
256
+ ? recoverSuppressedToolCallText(result.rawText)
257
+ : result.text;
258
+
259
+ let matchedStopSequence: string | null = null;
260
+ let responseResult = result;
261
+ if (stopSequences.length > 0) {
262
+ const stopBuffer = new StopSequenceBuffer(stopSequences);
263
+ const pushed = stopBuffer.push(visibleText);
264
+ const flushed = stopBuffer.flush();
265
+ const matched = pushed.matched ?? flushed.matched;
266
+ if (matched !== null) {
267
+ matchedStopSequence = matched;
268
+ responseResult = { ...result, text: pushed.safeText + flushed.safeText };
269
+ }
270
+ }
271
+
272
+ // `result.performance` is only populated when `reportPerformance: true`
273
+ // rides on the underlying `ChatConfig`; otherwise the field is
274
+ // `undefined` and the mapper elides the wire-extension fields. The
275
+ // launcher wires the flag on for verbose-log builds and leaves it off
276
+ // by default, matching how `cachedTokens` is treated through
277
+ // `buildAnthropicResponse`.
278
+ const response = buildAnthropicResponse(
279
+ responseResult,
280
+ body,
281
+ messageId,
282
+ result.performance,
283
+ requestAllowsToolUse(body),
284
+ serverTiming,
285
+ matchedStopSequence,
286
+ );
287
+
288
+ // The request AbortSignal reaches the normal session method, whose wrapper
289
+ // maps it to native cancellation at the next model safepoint. `endJson` keeps
290
+ // the final pre-entry destroyed check for the transport race after decode.
291
+ await endJson(res, JSON.stringify(response), visibility);
292
+ }
293
+
294
+ // Streaming path
295
+
296
+ /**
297
+ * Handler-side success signal for the streaming path. `ok === true` ONLY when
298
+ * we reached the clean `message_stop` terminal — i.e. `successful` was true at
299
+ * the post-loop gate. Every failure path that emits the streaming `error`
300
+ * terminal (mid-decode throw, client abort, `finishReason=error`, iterator
301
+ * exhaustion, missing-done) returns `ok: false`. The caller pairs this with
302
+ * the producer-side `wasCommitted()` to decide adopt vs. drop on the warm
303
+ * slot — both must be true to adopt. See the gate in `handleCreateMessage`.
304
+ */
305
+ interface MessagesStreamingHandlerResult {
306
+ ok: boolean;
307
+ suppressedToolCalls: boolean;
308
+ }
309
+
310
+ async function handleStreamingNative(
311
+ res: ServerResponse,
312
+ chatStream: AsyncGenerator<ChatStreamEvent>,
313
+ body: AnthropicMessagesRequest,
314
+ wasCommitted: () => boolean,
315
+ httpReq: IncomingMessage | undefined,
316
+ visibility: TransportVisibility,
317
+ emitReasoning: boolean,
318
+ stopSequences: string[],
319
+ serverTiming?: ServerTimingForUsage,
320
+ ): Promise<MessagesStreamingHandlerResult> {
321
+ const abort = trackSSEClientAbort(res, httpReq);
322
+ try {
323
+ return await handleStreamingNativeWithAbort(
324
+ res,
325
+ chatStream,
326
+ body,
327
+ wasCommitted,
328
+ abort,
329
+ visibility,
330
+ emitReasoning,
331
+ stopSequences,
332
+ serverTiming,
333
+ );
334
+ } finally {
335
+ abort.dispose();
336
+ }
337
+ }
338
+
339
+ async function handleStreamingNativeWithAbort(
340
+ res: ServerResponse,
341
+ chatStream: AsyncGenerator<ChatStreamEvent>,
342
+ body: AnthropicMessagesRequest,
343
+ wasCommitted: () => boolean,
344
+ abort: SSEClientAbortTracker,
345
+ visibility: TransportVisibility,
346
+ emitReasoning: boolean,
347
+ stopSequences: string[],
348
+ serverTiming?: ServerTimingForUsage,
349
+ ): Promise<MessagesStreamingHandlerResult> {
350
+ const messageId = genId('msg_');
351
+ // `runSessionStreaming` completed the exact token/capacity preflight before
352
+ // handing us this iterator. Commit SSE immediately instead of entering the
353
+ // generator here: its first `next()` also starts image processing/prefill and
354
+ // may not resolve until the first generated token.
355
+ beginSSE(res);
356
+ // Commit SSE wire format now so any throw before the terminal event routes
357
+ // to the streaming error epilogue instead of corrupting the JSON path.
358
+ markSSEMode(visibility);
359
+
360
+ // A native event can expand into several SSE frames. Preserve the first
361
+ // false write until the loop awaits it, and install the drain/close/error
362
+ // listeners immediately so an intervening iterator fetch cannot miss drain.
363
+ let pendingDrain: Promise<void> | null = null;
364
+ const writeSSEEvent = (response: ServerResponse, eventType: string, data: object): void => {
365
+ const ok = writeRawSSEEvent(response, eventType, data);
366
+ if (!ok && pendingDrain === null) {
367
+ pendingDrain = awaitDrainOrClose(response, { onTimeout: () => abort.markAborted() });
368
+ }
369
+ };
370
+ const drainPending = async (): Promise<void> => {
371
+ const drain = pendingDrain;
372
+ if (drain === null) return;
373
+ await drain;
374
+ if (pendingDrain === drain) pendingDrain = null;
375
+ };
376
+
377
+ let contentBlockIndex = 0;
378
+ let hasEmittedThinking = false;
379
+ let hasEmittedText = false;
380
+ let emittedTextLength = 0;
381
+ // Whitespace-only text seen before any non-whitespace content is buffered
382
+ // here so we don't open a text content block that the client would have
383
+ // to render as a stray `"\n\n"` immediately before a tool_use block.
384
+ // Flushed lazily when the first non-whitespace text delta arrives;
385
+ // dropped silently when a non-text block (tool_use) is about to open or
386
+ // when the stream ends without further text. Once `hasEmittedText` flips
387
+ // true (a real text block exists) this buffer is no longer consulted —
388
+ // subsequent whitespace-only deltas pass through to keep streamed text
389
+ // byte-accurate.
390
+ let pendingLeadingWhitespace = '';
391
+ // Mirror of the actual streamed text body, used by the malformed-tool-call
392
+ // recovery branches below. `emittedTextLength` counts bytes of streamed text
393
+ // — but `event.text` on the terminal `done` chunk is the post-</think>-trim
394
+ // cleaned text from the native `split_at_think_end`, so streamed and final
395
+ // prefixes can diverge (e.g. streamed=`"\n\n<tool_call>..."`,
396
+ // finalText=`"<tool_call>..."`). The recovery branches use
397
+ // `longestSuffixPrefixOverlap(emittedText, finalText)` to find the unsent
398
+ // suffix instead of a length-based slice that would chop characters.
399
+ let emittedText = '';
400
+ const tagBuffer = new ToolCallTagBuffer();
401
+ // Client-supplied `stop_sequences` detector. Feeds on the visible text that
402
+ // survives `tagBuffer` (structural-marker stripping) so it never sees tool
403
+ // markup. An empty `stopSequences` constructs a pass-through buffer
404
+ // (`push` returns its input verbatim, `flush` returns ''), so the wire is
405
+ // byte-identical to a request without `stop_sequences`. When a stop string
406
+ // matches, `matchedStopSequence` is recorded, all later visible text is
407
+ // suppressed, and the done-path emits nothing past the stop. The done-path
408
+ // also scans the terminal / recovered visible text on this SAME buffer (with
409
+ // any held partial still in place), so a stop straddling the stream/terminal
410
+ // boundary is caught with buffer continuity.
411
+ const stopBuffer = new StopSequenceBuffer(stopSequences);
412
+ let matchedStopSequence: string | null = null;
413
+
414
+ // Terminal emission is deferred until after the loop drains so `wasCommitted()`
415
+ // reads an authoritative `session.turns`. On a committed done chunk we emit
416
+ // `message_delta` + `message_stop`; on an uncommitted terminal (finishReason=error,
417
+ // mid-decode throw, client abort, iterator exhaustion) we emit a single streaming
418
+ // `error` event and withhold `message_stop`.
419
+ let sawDone = false;
420
+ let terminalStopReason: string | null = null;
421
+ let terminalNumTokens = 0;
422
+ let terminalPromptTokens: number | undefined;
423
+ // Captured from the terminal `done` chunk so the success-branch
424
+ // `buildMessageDelta` can emit Anthropic-spec cache accounting
425
+ // (`cache_read_input_tokens` + reduced `input_tokens`) on warm
426
+ // hits. Stays `undefined` on streams whose terminal chunk omits
427
+ // the field — mocks and any future in-process driver that hasn't
428
+ // adopted the surface — so `buildMessageDelta` falls back to the
429
+ // pre-Round-6 behaviour.
430
+ let terminalCachedTokens: number | undefined;
431
+ // Captured from the terminal `done` chunk so the success-branch
432
+ // `buildMessageDelta` can attach the server-extension perf fields
433
+ // (`time_to_first_token_ms`, `prefill_tokens_per_second`,
434
+ // `decode_tokens_per_second`). Stays `undefined` when the underlying
435
+ // dispatch did not opt into performance reporting (or when a mock
436
+ // bridge omits the field) — the mapper elides the fields rather
437
+ // than emitting zeros.
438
+ let terminalPerformance: PerformanceMetrics | undefined;
439
+ let terminalErrorMessage: string | null = null;
440
+ const allowToolUse = requestAllowsToolUse(body);
441
+ let suppressedToolCalls = false;
442
+
443
+ // `thrownError` sticks on a generator throw. The outer abort tracker remains
444
+ // armed through post-loop residual writes and the terminal flush as well as
445
+ // the decode loop itself.
446
+ let thrownError: Error | null = null;
447
+
448
+ // The outer wrapper's abort listeners precede the first body write so an
449
+ // asynchronous socket error is authoritative before the drain wait resumes.
450
+ writeSSEEvent(res, 'message_start', buildMessageStartEvent(body, messageId, 0));
451
+
452
+ try {
453
+ for await (const event of chatStream) {
454
+ await drainPending();
455
+ if (abort.aborted) break;
456
+ if (event.done) {
457
+ sawDone = true;
458
+
459
+ // An error terminal must NOT flush content blocks — doing so would race
460
+ // with the post-loop close and advertise a clean fan-out that the
461
+ // session rolled back.
462
+ if (event.finishReason === 'error') {
463
+ terminalErrorMessage = 'model reported finishReason=error';
464
+ break;
465
+ }
466
+
467
+ // Flush the tag buffer's residual but keep the stop buffer intact: it
468
+ // may still hold a partial from the streamed deltas, and a stop can
469
+ // straddle the boundary between that held partial, the tag residue, and
470
+ // the native terminal/recovered text. All of it runs through the SAME
471
+ // buffer, in stream order, with a single flush, BEFORE the stop match,
472
+ // the emitted text, and the tool decision are finalized.
473
+ const tagResidual = tagBuffer.flush();
474
+ const heldPartial = stopBuffer.pending;
475
+
476
+ const parsedToolCalls = event.toolCalls.filter((t) => t.status === 'ok');
477
+ if (!allowToolUse && parsedToolCalls.length > 0) {
478
+ suppressedToolCalls = true;
479
+ }
480
+ const finalText =
481
+ !allowToolUse &&
482
+ event.text.length === 0 &&
483
+ parsedToolCalls.length > 0 &&
484
+ containsToolCallMarkup(event.rawText)
485
+ ? recoverSuppressedToolCallText(event.rawText)
486
+ : event.text;
487
+
488
+ // The visible text the stream already RECEIVED, in stream order: what
489
+ // reached the wire (`emittedText`), the parked leading whitespace the
490
+ // detector already cleared but no block has shown yet
491
+ // (`pendingLeadingWhitespace`), the still-held detector partial
492
+ // (`heldPartial`), and the tag residue about to be scanned
493
+ // (`tagResidual`). Every parked/held byte is counted exactly once so the
494
+ // recovered terminal text is only the suffix of `finalText` the stream
495
+ // has not already accounted for — omitting the parked whitespace would
496
+ // make a full-text `finalText` look entirely unsent and replay the
497
+ // already-received prefix.
498
+ const streamedReceived = emittedText + pendingLeadingWhitespace + heldPartial + tagResidual;
499
+ let recoveredTail = '';
500
+ if (finalText) {
501
+ if (!hasEmittedText && heldPartial.length === 0 && tagResidual.length === 0) {
502
+ // Nothing was streamed or held: the whole `finalText` is terminal.
503
+ recoveredTail = finalText;
504
+ } else if (!streamedReceived.includes(finalText)) {
505
+ // `finalText` extends past what the stream produced: recover the
506
+ // suffix beyond the longest overlap. The `includes` guard skips the
507
+ // duplicate-trim case where `finalText` is a substring of the
508
+ // received text (native `.trim()` / post-`</think>` shrinkage).
509
+ recoveredTail = finalText.slice(longestSuffixPrefixOverlap(streamedReceived, finalText));
510
+ }
511
+ }
512
+
513
+ // One continuous scan — held partial (already buffered) + tag residue +
514
+ // recovered tail, in stream order, with a single flush at the end. A
515
+ // stop matched anywhere here is caught with buffer continuity, and
516
+ // `matchedStopSequence` is finalized before tool emission and
517
+ // `stop_reason`. With an empty `stopSequences` the buffer is a
518
+ // pass-through, so `terminalVisible` equals the released text verbatim.
519
+ let terminalVisible = '';
520
+ for (const segment of [tagResidual, recoveredTail]) {
521
+ const pushed = stopBuffer.push(segment);
522
+ if (pushed.matched !== null) {
523
+ matchedStopSequence = pushed.matched;
524
+ }
525
+ terminalVisible += pushed.safeText;
526
+ }
527
+ const flushed = stopBuffer.flush();
528
+ if (flushed.matched !== null) {
529
+ matchedStopSequence = flushed.matched;
530
+ }
531
+ terminalVisible += flushed.safeText;
532
+
533
+ // Parked leading whitespace belongs in front of RELEASED held content
534
+ // (a stop-buffer partial or tag residue). When the terminal text is
535
+ // purely native `finalText` recovery, `finalText` already carries that
536
+ // whitespace, so prepending it would double those bytes.
537
+ const prependParked = heldPartial.length > 0 || tagResidual.length > 0;
538
+
539
+ // Close a dangling reasoning block before any terminal text block opens.
540
+ if (hasEmittedThinking && !hasEmittedText) {
541
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
542
+ }
543
+
544
+ if (hasEmittedText) {
545
+ // A text block is already open from the streamed deltas: append the
546
+ // newly released terminal text (if any), then close the block.
547
+ if (terminalVisible) {
548
+ emittedText += terminalVisible;
549
+ emittedTextLength += terminalVisible.length;
550
+ writeSSEEvent(
551
+ res,
552
+ 'content_block_delta',
553
+ buildContentBlockDelta(contentBlockIndex, {
554
+ type: 'text_delta',
555
+ text: terminalVisible,
556
+ }),
557
+ );
558
+ }
559
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
560
+ contentBlockIndex++;
561
+ pendingLeadingWhitespace = '';
562
+ } else {
563
+ // No text block open yet. Build the block body from the terminal text
564
+ // (fronted by parked whitespace only when it precedes released held
565
+ // content), or from the parked whitespace alone when a stop truncated
566
+ // the turn right after it.
567
+ const body = terminalVisible
568
+ ? prependParked
569
+ ? pendingLeadingWhitespace + terminalVisible
570
+ : terminalVisible
571
+ : matchedStopSequence !== null
572
+ ? pendingLeadingWhitespace
573
+ : '';
574
+ pendingLeadingWhitespace = '';
575
+ // Open a text block for non-whitespace content, for a stop-truncated
576
+ // prefix (so the streamed body equals the non-streaming one), or for
577
+ // pure native `finalText` recovery (mirrors emitting recovered text
578
+ // verbatim). A stop-matched turn always opens a text block — empty if
579
+ // the stop consumed all visible output — so the reconstructed content
580
+ // matches the non-streaming `[{type:'text', text:''}]`. Whitespace-only
581
+ // released held content opens no block.
582
+ const openTextBlock =
583
+ matchedStopSequence !== null || (body.length > 0 && (body.trim().length > 0 || !prependParked));
584
+ if (openTextBlock) {
585
+ hasEmittedText = true;
586
+ writeSSEEvent(
587
+ res,
588
+ 'content_block_start',
589
+ buildContentBlockStart(contentBlockIndex, {
590
+ type: 'text',
591
+ text: '',
592
+ }),
593
+ );
594
+ if (body.length > 0) {
595
+ emittedText += body;
596
+ emittedTextLength += body.length;
597
+ writeSSEEvent(
598
+ res,
599
+ 'content_block_delta',
600
+ buildContentBlockDelta(contentBlockIndex, {
601
+ type: 'text_delta',
602
+ text: body,
603
+ }),
604
+ );
605
+ }
606
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
607
+ contentBlockIndex++;
608
+ }
609
+ }
610
+
611
+ // Decide tool emission only AFTER the full terminal stop scan: when a
612
+ // stop matched ANYWHERE (the pre-tool visible text OR the
613
+ // terminal/recovered text) the tool calls are suppressed so a streamed
614
+ // turn never carries both a tool_use block and
615
+ // `stop_reason: 'stop_sequence'`. This keeps streaming in lockstep with
616
+ // the non-streaming path (`buildAnthropicResponse`).
617
+ const okToolCalls = allowToolUse && matchedStopSequence === null ? parsedToolCalls : [];
618
+ const hasToolCalls = okToolCalls.length > 0;
619
+
620
+ for (const tc of okToolCalls) {
621
+ // Translate native `call_<uuid>` ids (minted by the Rust parser,
622
+ // which keeps the OpenAI Responses convention) into the
623
+ // Anthropic-spec `toolu_<uuid>` shape at the wire boundary.
624
+ // The `genId('toolu_')` fallback covers the case where the
625
+ // native side did not populate an id (an in-process driver or
626
+ // a legacy bridge).
627
+ const toolId = tc.id != null ? internalToolCallIdToAnthropic(tc.id) : genId('toolu_');
628
+ const parsedInput =
629
+ typeof tc.arguments === 'string'
630
+ ? (JSON.parse(tc.arguments) as Record<string, unknown>)
631
+ : (tc.arguments as Record<string, unknown>);
632
+
633
+ writeSSEEvent(
634
+ res,
635
+ 'content_block_start',
636
+ buildContentBlockStart(contentBlockIndex, {
637
+ type: 'tool_use',
638
+ id: toolId,
639
+ name: tc.name,
640
+ input: {},
641
+ }),
642
+ );
643
+ writeSSEEvent(
644
+ res,
645
+ 'content_block_delta',
646
+ buildContentBlockDelta(contentBlockIndex, {
647
+ type: 'input_json_delta',
648
+ partial_json: JSON.stringify(parsedInput),
649
+ }),
650
+ );
651
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
652
+ contentBlockIndex++;
653
+ }
654
+
655
+ // Capture terminal state and break — actual `message_delta` / `message_stop` /
656
+ // `error` emission is deferred until after the loop so `wasCommitted()` reads
657
+ // an authoritative `session.turns` (the producer's finally runs on break).
658
+ terminalStopReason = mapStopReason(event.finishReason, hasToolCalls, matchedStopSequence);
659
+ terminalNumTokens = event.numTokens;
660
+ terminalPromptTokens = event.promptTokens;
661
+ terminalCachedTokens = event.cachedTokens;
662
+ terminalPerformance = event.performance;
663
+ break;
664
+ }
665
+
666
+ // Delta event
667
+ if (event.isReasoning) {
668
+ if (!emitReasoning) continue;
669
+ const deltaText = event.text.replace(/<\/think>/g, '');
670
+ if (!deltaText) continue;
671
+
672
+ if (!hasEmittedThinking) {
673
+ hasEmittedThinking = true;
674
+ writeSSEEvent(
675
+ res,
676
+ 'content_block_start',
677
+ buildContentBlockStart(contentBlockIndex, {
678
+ type: 'thinking',
679
+ thinking: '',
680
+ }),
681
+ );
682
+ contentBlockIndex++;
683
+ }
684
+ writeSSEEvent(
685
+ res,
686
+ 'content_block_delta',
687
+ buildContentBlockDelta(contentBlockIndex - 1, {
688
+ type: 'thinking_delta',
689
+ thinking: deltaText,
690
+ }),
691
+ );
692
+ } else {
693
+ // Text delta with structural-marker buffering. Even when the
694
+ // request did not advertise tools, model-side tool/channel/turn
695
+ // markers are transport structure, not user-visible text.
696
+ const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
697
+ if (tagFound) {
698
+ // A structural tag (`<tool_call>` etc.) follows, so the visible
699
+ // text before it terminates here. Only `cleanPrefix` is fresh
700
+ // model text — route it through the stop-sequence detector so a
701
+ // configured stop string landing in it (e.g. "...HALT " right
702
+ // before a `<tool_call>`) is honored, not leaked. Do NOT flush the
703
+ // detector here: a held partial (e.g. "HA" of "HALT") must stay
704
+ // buffered, because the native cleaned done text can reconstitute
705
+ // the bytes that followed the suppressed tag and complete the stop
706
+ // across that boundary. The done-path scans the held partial
707
+ // together with the terminal/recovered text and resolves it —
708
+ // releasing it as visible text if it cannot complete, or suppressing
709
+ // it if it does. `pendingLeadingWhitespace` is whitespace the
710
+ // detector already cleared on an earlier delta (held back only
711
+ // because no text block was open yet), so it is prepended OUTSIDE
712
+ // the buffer: re-pushing it would double-scan it AND, because the
713
+ // buffer queues it after any held partial, invert stream order
714
+ // (e.g. held "H" + buffered " " -> "H ") or forge a false match. On
715
+ // a match `matchedStopSequence` is recorded so the terminal reports
716
+ // `stop_sequence`. With an empty `stopSequences` the detector is a
717
+ // pass-through, so `visibleText === pendingLeadingWhitespace +
718
+ // cleanPrefix` and the wire is byte-identical to today.
719
+ const stopPushed = stopBuffer.push(cleanPrefix);
720
+ if (stopPushed.matched !== null) {
721
+ matchedStopSequence = stopPushed.matched;
722
+ }
723
+ const visibleText = pendingLeadingWhitespace + stopPushed.safeText;
724
+ // Mirror the original `cleanPrefix.trim()` gate, now on the
725
+ // detector's safe text: emit only when there is non-whitespace to
726
+ // show, so a pure-whitespace prefix never ratifies a stray
727
+ // whitespace-only text block before the tool_use frame. When the
728
+ // safe text is whitespace-only (e.g. the detector is still holding a
729
+ // partial), KEEP it parked so the done-path can join it with
730
+ // whatever the held partial releases — clearing it here would drop
731
+ // it before that text block opens.
732
+ if (visibleText.trim().length > 0) {
733
+ if (!hasEmittedText) {
734
+ if (hasEmittedThinking) {
735
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
736
+ }
737
+ hasEmittedText = true;
738
+ writeSSEEvent(
739
+ res,
740
+ 'content_block_start',
741
+ buildContentBlockStart(contentBlockIndex, {
742
+ type: 'text',
743
+ text: '',
744
+ }),
745
+ );
746
+ }
747
+ pendingLeadingWhitespace = '';
748
+ emittedText += visibleText;
749
+ emittedTextLength += visibleText.length;
750
+ writeSSEEvent(
751
+ res,
752
+ 'content_block_delta',
753
+ buildContentBlockDelta(contentBlockIndex, {
754
+ type: 'text_delta',
755
+ text: visibleText,
756
+ }),
757
+ );
758
+ } else {
759
+ pendingLeadingWhitespace = hasEmittedText ? '' : visibleText;
760
+ }
761
+ } else if (safeText) {
762
+ // Run the tag-buffer's visible text through the stop-sequence
763
+ // detector. `visibleText` is what survives suppression: the buffer
764
+ // holds back a trailing suffix that could be the start of a stop
765
+ // string (released on a later push or at flush), and once a full
766
+ // stop string matches it returns empty `safeText` for the rest of
767
+ // the stream. We record the match and keep consuming so the native
768
+ // `done` chunk still fires the commit gate and history commit.
769
+ const stopResult = stopBuffer.push(safeText);
770
+ if (stopResult.matched !== null) {
771
+ matchedStopSequence = stopResult.matched;
772
+ }
773
+ const visibleText = stopResult.safeText;
774
+ if (visibleText) {
775
+ if (!hasEmittedText) {
776
+ // Hold back leading whitespace-only text so a `\n\n` emitted
777
+ // right before a `<tool_call>` tag never gets ratified into a
778
+ // standalone text content block. We can't open the block now
779
+ // because we don't yet know whether the next event is a real
780
+ // text delta (in which case the buffered prefix is flushed
781
+ // together with it) or a structural tag (in which case the
782
+ // buffer is dropped silently at tag-found / done time). When
783
+ // any non-whitespace arrives we ratify the block exactly
784
+ // once with `pendingLeadingWhitespace + visibleText`.
785
+ const combined = pendingLeadingWhitespace + visibleText;
786
+ if (combined.trim().length === 0) {
787
+ pendingLeadingWhitespace = combined;
788
+ } else {
789
+ if (hasEmittedThinking) {
790
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
791
+ }
792
+ hasEmittedText = true;
793
+ writeSSEEvent(
794
+ res,
795
+ 'content_block_start',
796
+ buildContentBlockStart(contentBlockIndex, {
797
+ type: 'text',
798
+ text: '',
799
+ }),
800
+ );
801
+ pendingLeadingWhitespace = '';
802
+ emittedText += combined;
803
+ emittedTextLength += combined.length;
804
+ writeSSEEvent(
805
+ res,
806
+ 'content_block_delta',
807
+ buildContentBlockDelta(contentBlockIndex, {
808
+ type: 'text_delta',
809
+ text: combined,
810
+ }),
811
+ );
812
+ }
813
+ } else {
814
+ emittedText += visibleText;
815
+ emittedTextLength += visibleText.length;
816
+ writeSSEEvent(
817
+ res,
818
+ 'content_block_delta',
819
+ buildContentBlockDelta(contentBlockIndex, {
820
+ type: 'text_delta',
821
+ text: visibleText,
822
+ }),
823
+ );
824
+ }
825
+ }
826
+ }
827
+ }
828
+ }
829
+ } catch (err: unknown) {
830
+ // Capture into a sticky flag so the post-loop block routes through the failure
831
+ // epilogue (single streaming `error` event, no `message_stop`).
832
+ thrownError = err instanceof Error ? err : new Error(String(err));
833
+ } finally {
834
+ await drainPending();
835
+ }
836
+
837
+ // Success requires ALL of: sawDone, wasCommitted, no terminal error, no thrown
838
+ // error, no client abort. `terminalErrorMessage` is set when a stream done event
839
+ // arrives with `finishReason=error` (or other in-band model error paths) — those
840
+ // turns must route to the failure epilogue so we emit a streaming `error` and
841
+ // withhold `message_stop`. Every failure path emits a streaming `error` and
842
+ // withholds `message_stop`.
843
+ const committed = wasCommitted();
844
+ const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !abort.aborted;
845
+
846
+ if (successful) {
847
+ const stopReason = terminalStopReason ?? 'end_turn';
848
+ writeSSEEvent(
849
+ res,
850
+ 'message_delta',
851
+ buildMessageDelta(
852
+ stopReason,
853
+ terminalNumTokens,
854
+ terminalPromptTokens,
855
+ terminalCachedTokens,
856
+ terminalPerformance,
857
+ serverTiming,
858
+ matchedStopSequence,
859
+ ),
860
+ );
861
+ // HTTP/1.1 chunked-encoding trailer: report the engine's cache-hit
862
+ // count once the SSE stream has settled. The header has to wait
863
+ // for `terminalCachedTokens` because `beginSSE` flushes response
864
+ // headers before the dispatch returns. Trailer-aware clients
865
+ // (curl `--trailer-name`, custom HTTP libraries, the verbose
866
+ // logger's response listener) get the authoritative value;
867
+ // SSE-only clients get the same value via the `usage.cache_read_input_tokens`
868
+ // field on `message_delta`. The `Trailer: X-Cached-Tokens` header
869
+ // was announced before `beginSSE` flushed (see messages.ts call site).
870
+ if (typeof terminalCachedTokens === 'number' && terminalCachedTokens > 0) {
871
+ try {
872
+ res.addTrailers({ 'X-Cached-Tokens': String(terminalCachedTokens) });
873
+ } catch {
874
+ // res.addTrailers throws if headers/trailers were not announced
875
+ // up front — non-fatal; the SSE usage field still carries the value.
876
+ }
877
+ }
878
+ await drainPending();
879
+ // The residual drain may have settled because the transport closed or
880
+ // errored. Never emit/adopt a success terminal from the stale snapshot.
881
+ if (!abort.aborted) {
882
+ await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
883
+ endSSE(res);
884
+ return { ok: true, suppressedToolCalls };
885
+ }
886
+ }
887
+ // Close any dangling content block so the error frame lands at a clean state,
888
+ // then emit the streaming error. Never emit `message_stop` here — pairing it
889
+ // with an error would tell the client the turn completed cleanly.
890
+ if (hasEmittedThinking && !hasEmittedText) {
891
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
892
+ } else if (hasEmittedText) {
893
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
894
+ }
895
+ await drainPending();
896
+ let message: string;
897
+ if (thrownError != null) {
898
+ message = thrownError.message;
899
+ } else if (abort.aborted) {
900
+ message = 'client disconnected before the stream completed';
901
+ } else if (terminalErrorMessage != null) {
902
+ message = terminalErrorMessage;
903
+ } else if (sawDone) {
904
+ message = 'model refused to commit the turn';
905
+ } else {
906
+ message = 'stream ended without a done event';
907
+ }
908
+ // The streaming `error` event is the Anthropic terminal on the failure path.
909
+ await flushTerminalSSE(res, 'error', { type: 'error', error: { type: 'api_error', message } }, visibility);
910
+ endSSE(res);
911
+ return { ok: false, suppressedToolCalls };
912
+ }
913
+
914
+ // Session routing
915
+
916
+ /**
917
+ * Non-streaming dispatch outcome. Mirrors the `{ result, committed }`
918
+ * shape from the sibling `/v1/responses` endpoint
919
+ * (`responses.ts:1361-1362`) so the warm-slot adopt site can dual-gate
920
+ * on commit success. `committed` is measured against `initialTurns`
921
+ * captured AFTER `primeHistory` AND requires a non-error
922
+ * `finishReason` — see the comment at the gate inside the helper.
923
+ */
924
+ interface MessagesNonStreamingOutcome {
925
+ result: ChatResult;
926
+ committed: boolean;
927
+ }
928
+
929
+ /** Prime a session with the full history and run a single turn. */
930
+ async function runSessionNonStreaming(
931
+ session: ChatSession<SessionCapableModel>,
932
+ messages: ChatMessage[],
933
+ config: ChatConfig,
934
+ resetNativeCache: boolean,
935
+ signal?: AbortSignal,
936
+ ): Promise<MessagesNonStreamingOutcome> {
937
+ // Dual-branch reset gated by the caller's native-cache policy:
938
+ //
939
+ // * Full native reset (`resetNativeCache === true`) — run a full
940
+ // `session.reset()`. A fresh JS session does NOT imply a fresh
941
+ // native cache — the underlying `SessionCapableModel` is shared
942
+ // across `ChatSession` lifetimes via `ModelRegistry`, and its
943
+ // native `cached_token_history` persists across requests. After
944
+ // the native refactor moved the unconditional wipe out of
945
+ // `chat_session_start_sync` into the miss branch of
946
+ // `verify_cache_prefix_direct`, skipping the wipe here would
947
+ // silently reuse whatever prefix happened to overlap with the
948
+ // previous (unrelated) request — the cross-request
949
+ // cache-affinity side channel documented at length in
950
+ // `responses.ts` (around the matching `runSessionNonStreaming`
951
+ // branches). Only registry HITS are authorized for cache reuse.
952
+ //
953
+ // * Preserve native cache (`resetNativeCache === false`) — run the JS-only
954
+ // `resetPreservingNativeCacheForWarmReuse` so the registry-leased
955
+ // native KV cache stays alive for `verify_cache_prefix_direct` or
956
+ // the paged adapter's content-addressed prefix lookup to recover
957
+ // the reused prefix on this turn. `primeHistory` requires
958
+ // `turnCount === 0`, which the helper guarantees by wiping
959
+ // JS-side state only.
960
+ if (resetNativeCache) {
961
+ await session.reset();
962
+ } else {
963
+ await resetPreservingNativeCacheForWarmReuse(session);
964
+ }
965
+ session.primeHistory(messages);
966
+ const initialTurns = session.turns;
967
+ const result = await session.startFromHistory(config, { signal });
968
+ // Mirror the streaming-side dual-gate (`streamResult.ok &&
969
+ // outcome.wasCommitted()`) and the sibling `/v1/responses` adopt
970
+ // gate. `ChatSession.startFromHistory` advances `turnCount`
971
+ // unconditionally on a clean resolve, so `session.turns >
972
+ // initialTurns` alone never trips today — every native error path
973
+ // throws. The `finishReason !== 'error'` clause defends the
974
+ // invariant LOCALLY so a future Rust change that resolves
975
+ // `chat_session_start_sync` with `Ok(finish_reason="error")` cannot
976
+ // silently poison the warm slot.
977
+ const committed = session.turns > initialTurns && result.finishReason !== 'error';
978
+ return { result, committed };
979
+ }
980
+
981
+ /**
982
+ * Streaming dispatch outcome. `wasCommitted()` compares `session.turns` against
983
+ * the baseline captured AFTER `primeHistory`, matching the `/v1/responses`
984
+ * streaming commit gate. Called post-drain by the SSE writer to pick the
985
+ * terminal event (success → `message_stop`, failure → streaming `error`).
986
+ */
987
+ interface MessagesStreamingOutcome {
988
+ stream: AsyncGenerator<ChatStreamEvent>;
989
+ wasCommitted: () => boolean;
990
+ }
991
+
992
+ async function runSessionStreaming(
993
+ session: ChatSession<SessionCapableModel>,
994
+ messages: ChatMessage[],
995
+ config: ChatConfig,
996
+ signal: AbortSignal | undefined,
997
+ resetNativeCache: boolean,
998
+ ): Promise<MessagesStreamingOutcome> {
999
+ // Validate the exact canonical history before resetting either JS or native
1000
+ // state. This preserves a clean HTTP 400 path for context overflow while
1001
+ // keeping SSE header latency independent from image processing/prefill.
1002
+ const constrainedConfig = await session.preflightContextCapacity(messages, config);
1003
+ // Same dual-branch reset as `runSessionNonStreaming`: native-reset
1004
+ // requests wipe both JS and native state to block the cross-request
1005
+ // cache-affinity leak described at length in `responses.ts`; native-
1006
+ // preserving requests wipe JS-only so the non-paged warm slot or the
1007
+ // paged content-addressed cache can recover a verified native prefix.
1008
+ // `initialTurns` MUST be captured AFTER the reset zeroes `turns` so
1009
+ // the committed check reads correctly.
1010
+ if (resetNativeCache) {
1011
+ await session.reset();
1012
+ } else {
1013
+ await resetPreservingNativeCacheForWarmReuse(session);
1014
+ }
1015
+ session.primeHistory(messages);
1016
+ const initialTurns = session.turns;
1017
+ return {
1018
+ stream: session.startFromHistoryStream(constrainedConfig, signal),
1019
+ wasCommitted: () => session.turns > initialTurns,
1020
+ };
1021
+ }
1022
+
1023
+ // Public handler
1024
+
1025
+ export async function handleCreateMessage(
1026
+ res: ServerResponse,
1027
+ body: AnthropicMessagesRequest,
1028
+ registry: ModelRegistry,
1029
+ httpReq?: IncomingMessage,
1030
+ idleSweeper?: IdleSweeper | null,
1031
+ resolveModel?: (name: string) => Promise<void>,
1032
+ modelWorkCoordinator?: ModelWorkCoordinator,
1033
+ ): Promise<void> {
1034
+ if (modelWorkCoordinator) {
1035
+ registry.setModelLoadAdmissionCoordinator(modelWorkCoordinator);
1036
+ }
1037
+ const handlerStartedAt = Date.now();
1038
+ let serverModelResolveMs: number | undefined;
1039
+ // Split observability for the resolve path: a request that arrives
1040
+ // milliseconds after a peer's cold-load should not be billed the full
1041
+ // load latency as if it drove the load itself. `serverLoadWaitMs`
1042
+ // captures wall-clock spent blocked on the writer lock (whether
1043
+ // waiting on a peer or self-loading); `serverLoadOwner` is true only
1044
+ // when this request acquired the lock without contention.
1045
+ let serverLoadWaitMs: number | undefined;
1046
+ let serverLoadOwner: boolean | undefined;
1047
+
1048
+ if (body == null || typeof body !== 'object') {
1049
+ sendAnthropicBadRequest(res, 'Request body must be a JSON object');
1050
+ return;
1051
+ }
1052
+ if (!body.model) {
1053
+ sendAnthropicBadRequest(res, 'Missing required field: model');
1054
+ return;
1055
+ }
1056
+ if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
1057
+ sendAnthropicBadRequest(res, 'Missing required field: messages');
1058
+ return;
1059
+ }
1060
+ if (body.max_tokens == null || !Number.isInteger(body.max_tokens) || body.max_tokens <= 0) {
1061
+ sendAnthropicBadRequest(res, 'Missing required field: max_tokens');
1062
+ return;
1063
+ }
1064
+ if (body.max_tokens > MAX_OUTPUT_TOKENS) {
1065
+ // The field is present and a positive integer but too large: the native
1066
+ // `ChatConfig.max_new_tokens` is `i32`, and NAPI truncates a JS integer
1067
+ // above `i32::MAX` to a NEGATIVE value (then clamped to 0 → a silent empty
1068
+ // completion), so an over-large budget must 400 with a clear message
1069
+ // rather than be reported as "missing" or silently no-op.
1070
+ sendAnthropicBadRequest(res, `Field "max_tokens" must be an integer between 1 and ${MAX_OUTPUT_TOKENS}`);
1071
+ return;
1072
+ }
1073
+
1074
+ for (const msg of body.messages) {
1075
+ if (msg == null || typeof msg !== 'object') {
1076
+ sendAnthropicBadRequest(res, 'Each message must be a non-null object');
1077
+ return;
1078
+ }
1079
+ }
1080
+
1081
+ // Run the Anthropic→internal mapping BEFORE the lazy-load hook.
1082
+ //
1083
+ // Background: in `mlx launch claude` mode `resolveModel` may load a
1084
+ // 27GB model from disk (~30s) on first sight of an unknown name. If
1085
+ // we then fail mapping (unsupported role, malformed tool block, etc.)
1086
+ // we've burned a load — and possibly evicted the currently-resident
1087
+ // model — just to return 400 a moment later. Mapping is a pure
1088
+ // transform with no side effects, so it's safe to hoist above
1089
+ // resolveModel and use as a cheap pre-flight gate.
1090
+ let mappedMessages: ChatMessage[];
1091
+ let mappedConfig: ChatConfig;
1092
+ // Client-supplied `stop_sequences`, normalized by the mapper (absent/empty
1093
+ // dropped). Threaded into the streaming + non-streaming handlers, which own
1094
+ // the detection/truncation. `ChatConfig` has no native stop field, so this
1095
+ // rides alongside `config` from the mapper.
1096
+ let mappedStopSequences: string[];
1097
+ try {
1098
+ ({
1099
+ messages: mappedMessages,
1100
+ config: mappedConfig,
1101
+ stopSequences: mappedStopSequences,
1102
+ } = mapAnthropicRequest(body));
1103
+ } catch (err) {
1104
+ sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
1105
+ return;
1106
+ }
1107
+
1108
+ // Pre-dispatch admission gate (H3, host mode). Mirror of the
1109
+ // `/v1/responses` gate — see the long-form rationale there. In host
1110
+ // mode resident arrivals bypass the `ModelWorkCoordinator` writer bracket
1111
+ // below and proceed toward the continuous-batching lane, but can still park
1112
+ // in pre-lock work. Admit-or-429 (Anthropic envelope) against the same
1113
+ // per-model budget so that work stays bounded too. Non-resident (cold-load)
1114
+ // requests use the coordinator's bounded pre-resolution permit below
1115
+ // because no `SessionRegistry` exists yet. The permit is RETAINED through every pre-lock
1116
+ // await and handed to `withExclusive` at placement, which consumes it
1117
+ // atomically as this request's admission (one budget, one token,
1118
+ // never double-counted); it is released only on bail-out exits —
1119
+ // explicitly on the early returns before the outer `try`, and by the
1120
+ // outer `finally` for everything inside it (idempotent + no-op after
1121
+ // handoff).
1122
+ let preDispatchAdmission: PreDispatchAdmission | undefined;
1123
+ let modelLoadAdmission: ModelLoadAdmission | undefined;
1124
+ const preDispatchRegistry = registry.getSessionRegistry(body.model);
1125
+ if (preDispatchRegistry) {
1126
+ try {
1127
+ preDispatchAdmission = preDispatchRegistry.beginPreDispatchAdmission();
1128
+ } catch (err) {
1129
+ if (err instanceof QueueFullError) {
1130
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
1131
+ return;
1132
+ }
1133
+ throw err;
1134
+ }
1135
+ } else if (resolveModel && modelWorkCoordinator) {
1136
+ try {
1137
+ modelLoadAdmission = modelWorkCoordinator.beginRequestLoadAdmission(body.model);
1138
+ } catch (err) {
1139
+ if (err instanceof ModelLoadQueueFullError) {
1140
+ sendAnthropicRateLimit(
1141
+ res,
1142
+ `Model queue full: admission footprint ${err.admissionFootprint} (limit ${err.limit}). Retry after 1s.`,
1143
+ );
1144
+ return;
1145
+ }
1146
+ throw err;
1147
+ }
1148
+ }
1149
+
1150
+ // Lazy-load hook for a requested name with no resident registry: give the
1151
+ // host a chance to register it before we look it up. Resident requests skip
1152
+ // this writer bracket so they can reach the continuous-batching lane.
1153
+ //
1154
+ // The load is bracketed by `idleSweeper.withSuspendedDrains` so the
1155
+ // post-request drain timer armed by the PREVIOUS request's
1156
+ // `endRequest()` cannot fire mid-load. In `mlx launch claude` mode
1157
+ // `resolveModel` may invoke a 30s `loadModel()` on first sight of an
1158
+ // unknown name; if the prior request's matching `endRequest()`
1159
+ // armed the default 30s drain immediately before this load began,
1160
+ // the timer would otherwise call `clearCache()` while weight
1161
+ // materialization was still allocating through the Metal free pool —
1162
+ // exactly the hot-load race `withSuspendedDrains` exists to prevent.
1163
+ // The wrapper handles try/finally itself and is a pass-through on
1164
+ // the disabled sweeper, so the bracket is unconditional whenever
1165
+ // a sweeper is supplied.
1166
+ if (resolveModel && !preDispatchRegistry) {
1167
+ // A throw here (bad model path, corrupt weights, native loader failure)
1168
+ // would otherwise bubble up to the outer `createHandler` catch which
1169
+ // emits the OpenAI-shape `{ error: ... }` envelope via `sendInternalError`.
1170
+ // This endpoint is Anthropic; clients parse the
1171
+ // `{ type: 'error', error: { type, message } }` shape, so we must
1172
+ // serialize the failure through `sendAnthropicInternalError` here. Mirrors
1173
+ // the `mapAnthropicRequest` try/catch above.
1174
+ try {
1175
+ const resolveStartedAt = Date.now();
1176
+ const runResolve = () =>
1177
+ idleSweeper ? idleSweeper.withSuspendedDrains(() => resolveModel(body.model)) : resolveModel(body.model);
1178
+ if (modelWorkCoordinator) {
1179
+ // Use the instrumented variant so we can tell whether this
1180
+ // request actually drove the load (owner) or merely parked
1181
+ // behind a peer's in-flight load. Without the split, two
1182
+ // requests racing into a 60s cold-load both report
1183
+ // `resolve_ms=60000` and observers can't tell which one paid
1184
+ // the cost vs. inherited the wait.
1185
+ //
1186
+ // The coordinator internally partitions the call into a wait
1187
+ // phase (`acquireWrite()`) and an own-execution phase (`fn`)
1188
+ // so `waitMs + ownMs` covers the total elapsed time without
1189
+ // overlap. We plumb them straight through into the matching
1190
+ // observability fields:
1191
+ // - owner driving a cold load → waitMs ≈ 0, ownMs ≈ load duration
1192
+ // - follower parked behind peer → waitMs ≈ peer load, ownMs ≈ 0
1193
+ // Resident requests bypass this block entirely and therefore omit all
1194
+ // three load-timing fields.
1195
+ // This matches the documented contract in `timing.ts` where
1196
+ // `server_model_resolve_ms` excludes peer-wait time.
1197
+ const outcome = await modelWorkCoordinator.withModelLoadInstrumented(runResolve);
1198
+ serverLoadOwner = outcome.owner;
1199
+ serverLoadWaitMs = outcome.waitMs;
1200
+ serverModelResolveMs = outcome.ownMs;
1201
+ } else {
1202
+ await runResolve();
1203
+ serverModelResolveMs = Date.now() - resolveStartedAt;
1204
+ }
1205
+ } catch (err) {
1206
+ preDispatchAdmission?.release();
1207
+ modelLoadAdmission?.release();
1208
+ sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
1209
+ return;
1210
+ }
1211
+ }
1212
+ // NOTE: the permit is NOT released here. The request must stay counted
1213
+ // through the remaining pre-lock work until `withExclusive` consumes
1214
+ // the permit at placement. Only bail-out exits release.
1215
+
1216
+ const model = registry.get(body.model);
1217
+ if (!model) {
1218
+ preDispatchAdmission?.release();
1219
+ modelLoadAdmission?.release();
1220
+ sendAnthropicNotFound(res, `Model "${body.model}" not found`);
1221
+ return;
1222
+ }
1223
+
1224
+ // The lease keeps the binding's FIFO `execLock` chain alive across every
1225
+ // await — a concurrent `unregister()` + `register(sameModel)` would otherwise
1226
+ // tear down the old `SessionRegistry` and race two independent mutex chains
1227
+ // against one shared native model. Must be released in the `finally` below.
1228
+ const lease = registry.acquireDispatchLease(body.model);
1229
+ if (!lease) {
1230
+ preDispatchAdmission?.release();
1231
+ modelLoadAdmission?.release();
1232
+ sendAnthropicInternalError(res, 'session registry missing for registered model');
1233
+ return;
1234
+ }
1235
+ const leaseModel = lease.model;
1236
+ if (modelLoadAdmission) {
1237
+ try {
1238
+ preDispatchAdmission = modelLoadAdmission.transferToResident(lease.registry);
1239
+ } catch (err) {
1240
+ registry.releaseDispatchLease(leaseModel);
1241
+ modelLoadAdmission.release();
1242
+ if (err instanceof QueueFullError) {
1243
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
1244
+ return;
1245
+ }
1246
+ throw err;
1247
+ }
1248
+ }
1249
+ // AbortController wired to disconnect events. Declared at function scope
1250
+ // so the outer `finally` can detach listeners on early returns; the
1251
+ // `abortListenersAttached` flag gates the detach so pre-validation exits
1252
+ // skip it safely.
1253
+ const abortController = new AbortController();
1254
+ const abortSocket = res.socket;
1255
+ const onAbortClose = (): void => {
1256
+ abortController.abort();
1257
+ };
1258
+ const onAbortError = (_err: unknown): void => {
1259
+ abortController.abort();
1260
+ };
1261
+ let abortListenersAttached = false;
1262
+ // Idle-sweeper bracket flags — hoisted so the outer `finally` can
1263
+ // observe whether the `beginRequest()` bump ever happened. Early
1264
+ // validation-failure returns skip the bump and therefore also skip
1265
+ // the matching `endRequest()`. `idleRequestEnded` is the `done`
1266
+ // flag that guarantees the decrement fires exactly once regardless
1267
+ // of which finalize path — outer `finally`, `finish`, `close`,
1268
+ // `error` — wins the race.
1269
+ //
1270
+ // Listeners are attached EAGERLY at `beginRequest()` time, not
1271
+ // lazily from the outer `finally`. The round-4 review surfaced a
1272
+ // leak where a terminal socket event fired before the outer
1273
+ // `finally` ran: the lazy attach saw `writableEnded === false` at
1274
+ // check time, attached listeners on a socket whose terminal event
1275
+ // had already been emitted, and `endRequest()` then never fired,
1276
+ // leaving `inFlight` pinned above zero and the sweeper permanently
1277
+ // armed.
1278
+ let idleRequestStarted = false;
1279
+ let idleRequestEnded = false;
1280
+ let idleListenersAttached = false;
1281
+ const finalizeIdleRequest = (): void => {
1282
+ if (!idleRequestStarted) return;
1283
+ if (idleRequestEnded) return;
1284
+ idleRequestEnded = true;
1285
+ idleSweeper?.endRequest();
1286
+ };
1287
+ const onFinalizeEvent = (): void => {
1288
+ finalizeIdleRequest();
1289
+ };
1290
+ try {
1291
+ const sessionReg: SessionRegistry = lease.registry;
1292
+ mappedConfig = applyOutputTokenLimit(mappedConfig, sessionReg.outputTokenLimit);
1293
+ mappedConfig = applyClaudeCodeTitleFastPath(mappedConfig, body);
1294
+ // Snapshot the monotonic instance id so the in-mutex re-read can detect a
1295
+ // hot-swap that lands between lease acquisition and mutex entry. Unlike
1296
+ // `/v1/responses`, the Anthropic handler has no stored-identity check
1297
+ // downstream to catch the race later.
1298
+ const preLockInstanceId: number = lease.instanceId;
1299
+
1300
+ // `mapAnthropicRequest` already ran (and succeeded) above as a cheap
1301
+ // pre-flight gate before `resolveModel` so a malformed request can't
1302
+ // trigger a multi-second model load just to 400 a moment later.
1303
+ const messages: ChatMessage[] = mappedMessages;
1304
+ const config: ChatConfig = mappedConfig;
1305
+ const stopSequences: string[] = mappedStopSequences;
1306
+
1307
+ // Canonicalize every assistant fan-out's trailing tool block against its
1308
+ // declared sibling order. Several native session backends pair tool results
1309
+ // to fan-out calls POSITIONALLY (not by id), so caller-reversed sibling
1310
+ // results would silently bind to the wrong call. `'anthropic'` selects
1311
+ // error-message vocabulary (`tool_result` / `tool_use_id`).
1312
+ const historyError = validateAndCanonicalizeHistoryToolOrder(messages, 'anthropic');
1313
+ if (historyError !== null) {
1314
+ sendAnthropicBadRequest(res, historyError);
1315
+ return;
1316
+ }
1317
+
1318
+ // The system prompt is baked into `messages` and replayed via `startFromHistory`,
1319
+ // so it cannot leak across requests. We still pass a canonicalized form to
1320
+ // `getOrCreate` to keep the registry API uniform with `/v1/responses`. The
1321
+ // helper is shared with `mapAnthropicRequest`'s system loop so the cache-key
1322
+ // view and the mapped messages can never drift — both drop the rotating
1323
+ // Anthropic billing-header block (cf. `canonicalizeSystemForCacheKey`).
1324
+ const requestedSystem = canonicalizeSystemForCacheKey(body.system);
1325
+
1326
+ // Per-model execution mutex. Every dispatch through `/v1/messages` serializes
1327
+ // with every dispatch through `/v1/responses` for the same model binding.
1328
+ // The native `SessionCapableModel` is a single mutable resource (shared
1329
+ // `cached_token_history` / `caches`), so two concurrent `primeHistory` +
1330
+ // `startFromHistory` would clobber each other's KV state.
1331
+ //
1332
+ // Arm the AbortController now — past all validation gates, so the
1333
+ // matching detach in the outer `finally` is guarded by
1334
+ // `abortListenersAttached`. Streaming wrappers in `@mlx-node/lm` plumb
1335
+ // this signal through `_runChatStream` to cancel the native
1336
+ // `ChatStreamHandle` and unblock the pending `waitForItem()` on
1337
+ // disconnect.
1338
+ res.once('close', onAbortClose);
1339
+ res.once('error', onAbortError);
1340
+ if (abortSocket != null) {
1341
+ abortSocket.once('close', onAbortClose);
1342
+ }
1343
+ if (httpReq) {
1344
+ httpReq.once('close', onAbortClose);
1345
+ httpReq.once('error', onAbortError);
1346
+ }
1347
+ // Catch-up abort: a response torn down BEFORE the attach above has
1348
+ // already emitted its terminal event, so the `once('close')`
1349
+ // listeners will never fire. Consult the response-side socket state
1350
+ // directly (the REQUEST side is deliberately excluded — a fully
1351
+ // consumed IncomingMessage auto-destroys after 'end' on every
1352
+ // normal request, so `httpReq.destroyed` is not a disconnect
1353
+ // signal). Makes `streamSignal.aborted` authoritative for the H2
1354
+ // pre-dispatch disconnect check inside the mutex callback.
1355
+ if (res.destroyed || res.writableEnded || abortSocket?.destroyed === true) {
1356
+ abortController.abort();
1357
+ }
1358
+ abortListenersAttached = true;
1359
+ const streamSignal: AbortSignal = abortController.signal;
1360
+
1361
+ // Bracket the native-model dispatch with the idle sweeper.
1362
+ // Scoped here (past validation, before any native prefill /
1363
+ // decode) so purely observational endpoints and pre-validation
1364
+ // rejections do not push the sweeper's pending-drain timer out.
1365
+ //
1366
+ // Attach the terminal-event listeners BEFORE any `await` — the
1367
+ // round-4 fix for a leak where a fast terminal event fired
1368
+ // before the outer `finally` attached its listeners, leaving
1369
+ // `inFlight` pinned above zero. `finalizeIdleRequest` is
1370
+ // idempotent (guarded by `idleRequestEnded`) so whichever path
1371
+ // wins — listeners, outer `finally`, or a pre-dispatch early
1372
+ // return — the decrement fires exactly once.
1373
+ idleSweeper?.beginRequest();
1374
+ idleRequestStarted = true;
1375
+ res.once('finish', onFinalizeEvent);
1376
+ res.once('close', onFinalizeEvent);
1377
+ res.once('error', onFinalizeEvent);
1378
+ idleListenersAttached = true;
1379
+
1380
+ try {
1381
+ const mutexQueuedAt = Date.now();
1382
+ const runInference = () => {
1383
+ return withAdmissionControlledInference(sessionReg, modelWorkCoordinator, preDispatchAdmission, async () => {
1384
+ const serverTiming: ServerTimingForUsage = {
1385
+ server_model_resolve_ms: serverModelResolveMs,
1386
+ server_load_wait_ms: serverLoadWaitMs,
1387
+ server_load_owner: serverLoadOwner,
1388
+ server_queue_ms: Date.now() - mutexQueuedAt,
1389
+ server_pre_inference_ms: Date.now() - handlerStartedAt,
1390
+ ...resolveServerTuningForUsage(),
1391
+ };
1392
+ // Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
1393
+ // `withExclusive`, so a concurrent re-register of the same friendly name
1394
+ // could silently dispatch this request through a stale model. Any drift
1395
+ // from the pre-lock snapshot is fatal.
1396
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
1397
+ const lockedInstanceId = registry.getInstanceId(body.model);
1398
+ if (
1399
+ lockedSessionReg === undefined ||
1400
+ lockedInstanceId === undefined ||
1401
+ lockedSessionReg !== sessionReg ||
1402
+ lockedInstanceId !== preLockInstanceId
1403
+ ) {
1404
+ sendAnthropicBadRequest(
1405
+ res,
1406
+ `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
1407
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
1408
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
1409
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
1410
+ `service this request through a stale model object — a silent cross-model handoff. Retry the ` +
1411
+ `request — if the swap was intentional, the new binding will service the retry cleanly.`,
1412
+ );
1413
+ return;
1414
+ }
1415
+
1416
+ // Per-model session selection for `/v1/messages` reuse.
1417
+ //
1418
+ // Two paths, gated on whether the underlying native model has
1419
+ // the block-paged KV cache adapter active
1420
+ // (`hasBlockPagedCache()` — captured at load time from
1421
+ // `<Inner>::paged_adapter.is_some()` and surfaced by the
1422
+ // `SessionCapableModel` structural interface):
1423
+ //
1424
+ // * **Paged-active** (Qwen3 + LFM2 + Gemma4 today; Qwen3.5
1425
+ // dense + Qwen3.5 MoE once their perf trade-off is
1426
+ // decided). Allocate a fresh `ChatSession` per request via
1427
+ // `createFreshSession()`, do NOT touch the warm slot.
1428
+ // Cross-turn / cross-conversation prefix reuse is handled
1429
+ // entirely by the native `BlockAllocator`'s prefix-hash
1430
+ // table: SYS blocks shared across requests are refcounted
1431
+ // transparently, so two parallel `/v1/messages` requests
1432
+ // sharing a system prompt both run on distinct
1433
+ // `ChatSession` objects but reference the SAME physical
1434
+ // KV blocks. The JS-side warm slot would only serialize
1435
+ // them and force one into cold replay.
1436
+ //
1437
+ // * **Non-paged** (Qwen3.5 dense + MoE — default-OFF pending
1438
+ // a perf decision against the compiled C++ flat path;
1439
+ // the Qianfan-OCR VLM — no adapter wired). Fall through to
1440
+ // `getOrCreateWarmAny`, which is the ONLY cross-conversation
1441
+ // reuse mechanism these models have. The Anthropic Messages
1442
+ // API is stateless on the wire (no `previous_response_id`,
1443
+ // clients don't propagate `prompt_cache_key`), so without
1444
+ // the warm slot every turn is a full cold start.
1445
+ //
1446
+ // The `hasBlockPagedCache?()` getter is optional on the
1447
+ // structural interface so the `QianfanOCRModel` VLM (which
1448
+ // has no paged-adapter wiring) still satisfies the type
1449
+ // contract — a missing getter falls into the non-paged branch
1450
+ // here.
1451
+ //
1452
+ // Adoption stays keyed by the literal sentinel
1453
+ // `MESSAGES_WARM_SLOT_ID = '__msg_warm__'`. The Anthropic
1454
+ // Messages API never produces a `previous_response_id` clients
1455
+ // could echo back (and the OpenAI side mints `resp_*` ids),
1456
+ // so cross-endpoint capture via tier-1 is impossible by
1457
+ // construction — no `/v1/responses` request can collide with
1458
+ // the sentinel through the tier-1 path.
1459
+ //
1460
+ // The two endpoints DO share the single warm slot under the
1461
+ // registry's single-warm invariant on the non-paged path: a
1462
+ // `/v1/messages` turn following a `/v1/responses` turn can
1463
+ // evict (and vice versa). On the paged path neither side
1464
+ // touches the warm slot, so cross-endpoint contention
1465
+ // disappears.
1466
+ //
1467
+ // The `prompt_cache_key` request field is still NOT exposed
1468
+ // on this endpoint. Cross-conversation block-level cache
1469
+ // reuse on paged-active models is now driven by native
1470
+ // content-addressing instead of the JS warm slot, so adding
1471
+ // the field is no longer a prerequisite for that use case.
1472
+ // H2 pre-dispatch disconnect check (non-streaming only). A
1473
+ // client that vanished while this request was parked behind
1474
+ // the per-model mutex must not burn a whole prefill+decode
1475
+ // budget producing a JSON body nobody can receive. Checked
1476
+ // BEFORE the warm-slot lease so no session state is consumed
1477
+ // or mutated — the early return composes with the permit
1478
+ // lifecycle exactly like the binding-changed return above
1479
+ // (the pre-dispatch permit was already consumed atomically by
1480
+ // `withExclusive`; the outer `finally` release is an
1481
+ // idempotent no-op after handoff). Streaming keeps its
1482
+ // existing paths: the signal fast-aborts `_runChatStream` and
1483
+ // the SSE drain loop breaks on `clientAborted` at loop-top.
1484
+ if (body.stream !== true && streamSignal.aborted) {
1485
+ return;
1486
+ }
1487
+
1488
+ const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
1489
+ const lookup = pagedActive
1490
+ ? sessionReg.createFreshSession()
1491
+ : sessionReg.getOrCreateWarmAny(requestedSystem, config.cacheSalt ?? null);
1492
+ const session = lookup.session;
1493
+ await sessionReg.flushPendingDisposals();
1494
+ let sessionRetained = false;
1495
+ // `X-Session-Cache` observability header.
1496
+ //
1497
+ // Non-paged path:
1498
+ // * Non-streaming: set the optimistic `prefix_hit` value
1499
+ // BEFORE dispatch on `lookup.hit` (so the header is on the
1500
+ // wire even if the dispatch throws) and demote
1501
+ // post-dispatch to `fresh` when the warm slot was leased
1502
+ // but native prefix reuse did not actually happen
1503
+ // (`result.cachedTokens === 0`). `res.end` has not fired
1504
+ // yet, so the overwrite still lands on the wire.
1505
+ // * Streaming: emits `streaming` to signal the authoritative
1506
+ // post-dispatch value rides on the SSE stream
1507
+ // (`message_delta.usage.cache_read_input_tokens` and the
1508
+ // `X-Cached-Tokens` HTTP trailer, set below in
1509
+ // `handleStreamingNative` once `terminalCachedTokens` is
1510
+ // known). Reporting `fresh` here would be a lie — the
1511
+ // paged engine routinely returns `cachedTokens > 0` on
1512
+ // turn-2+ and the prior `'fresh'` default falsely advertised
1513
+ // a cache miss. The previous comment documented this as
1514
+ // intentional but it was a logging bug.
1515
+ //
1516
+ // Paged path:
1517
+ // * Non-streaming: `lookup.hit` is always `false` (we
1518
+ // `createFreshSession`); the post-dispatch promotion
1519
+ // branch flips `prefix_hit` when the native engine
1520
+ // reports `cachedTokens > 0`, which is the authoritative
1521
+ // signal that the block allocator's content-addressed
1522
+ // reuse picked up shared SYS blocks on this turn.
1523
+ // * Streaming: same `streaming` value as non-paged; the SSE
1524
+ // `usage.cache_read_input_tokens` field carries the
1525
+ // authoritative value.
1526
+ //
1527
+ // Header values: `'fresh' | 'prefix_hit' | 'streaming'`. The
1528
+ // `'streaming'` value tells operators to read the SSE
1529
+ // `message_delta.usage.cache_read_input_tokens` for the
1530
+ // resolved cache-hit count (or `X-Cached-Tokens` trailer if
1531
+ // the client supports HTTP trailers).
1532
+ let sessionCacheStatus: 'fresh' | 'prefix_hit' | 'streaming' =
1533
+ body.stream === true ? 'streaming' : lookup.hit ? 'prefix_hit' : 'fresh';
1534
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1535
+ // HTTP/1.1 chunked-encoding trailer announcement for streaming.
1536
+ // The actual value is filled in by `handleStreamingNative`
1537
+ // once it has captured `terminalCachedTokens` from the final
1538
+ // SSE chunk.
1539
+ if (body.stream === true) {
1540
+ res.setHeader('Trailer', 'X-Cached-Tokens');
1541
+ }
1542
+
1543
+ // Outer catch branches on `responseMode` (not `res.headersSent`, which
1544
+ // flips in `writeHead` before the body lands) so a crash after
1545
+ // `writeHead(application/json)` cannot leak SSE frames into a JSON body.
1546
+ const visibility = createVisibility();
1547
+
1548
+ try {
1549
+ if (body.stream === true) {
1550
+ // On the paged path the underlying native cache is the
1551
+ // sole reuse mechanism, so preserve it even though the JS
1552
+ // `ChatSession` is freshly allocated. The native paged
1553
+ // adapter validates reuse by token/hash before any cached
1554
+ // prefix is trusted, and the MoE GDN checkpoint layer now
1555
+ // follows the same content-checked policy. Non-paged keeps
1556
+ // the original `!lookup.hit` semantics so only warm-slot
1557
+ // hits preserve native cache.
1558
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
1559
+ const outcome = await runSessionStreaming(session, messages, config, streamSignal, resetNativeCache);
1560
+ const streamResult = await handleStreamingNative(
1561
+ res,
1562
+ outcome.stream,
1563
+ body,
1564
+ outcome.wasCommitted,
1565
+ httpReq,
1566
+ visibility,
1567
+ config.includeReasoning !== false,
1568
+ stopSequences,
1569
+ serverTiming,
1570
+ );
1571
+ // Warm-slot adopt/drop only applies to the non-paged
1572
+ // path. On the paged path the JS-side warm slot plays no
1573
+ // role (block reuse is content-addressed in native), so
1574
+ // we never touch it. The fresh `ChatSession` is explicitly
1575
+ // disposed in the `finally` below so its native scheduler owner
1576
+ // and live paged request are released before the handler leaves
1577
+ // the admission lane; GC alone cannot perform that native
1578
+ // lifecycle transition.
1579
+ //
1580
+ // Non-paged dual-gate adopt: BOTH the producer-side commit
1581
+ // signal (`outcome.wasCommitted()`, which reads
1582
+ // `session.turns` bumped in `startFromHistoryStream`'s
1583
+ // `finally`) AND the handler-side success signal
1584
+ // (`streamResult.ok`, true only when we reached the clean
1585
+ // `message_stop` terminal) must be true to adopt. The
1586
+ // producer's `finally` runs on every break — including
1587
+ // client abort, mid-decode throw, and
1588
+ // `finishReason=error` — so `wasCommitted()` alone is NOT
1589
+ // sufficient: it can return `true` after the SSE side
1590
+ // emitted an `error` terminal (not re-thrown by
1591
+ // `handleStreamingNative`), leaving a session whose
1592
+ // observable wire state is failure but whose `turns`
1593
+ // counter advanced. Adopting in that window would seed the
1594
+ // warm slot with a session the next request can lease but
1595
+ // whose history does not match what the client received.
1596
+ //
1597
+ // Mirrors `responses.ts` (around line 3277) where the
1598
+ // analogous gate combines `committed`, `handlerError`, and
1599
+ // `streamFailureMode === null` — the producer-side commit
1600
+ // and a clean handler-side terminal must both hold before
1601
+ // the session is reachable from a subsequent request.
1602
+ if (!pagedActive) {
1603
+ if (streamResult.ok && outcome.wasCommitted() && !streamResult.suppressedToolCalls) {
1604
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
1605
+ sessionRetained = true;
1606
+ } else {
1607
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1608
+ }
1609
+ }
1610
+ } else {
1611
+ // See the streaming branch above for the rationale on
1612
+ // preserving native cache on the paged path.
1613
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
1614
+ // Non-streaming cancellation (H2): `streamSignal` threads
1615
+ // through `ChatSession.startFromHistory` into the normal public
1616
+ // method; the wrapper maps it to the internal native operation — a mid-turn
1617
+ // disconnect flips the controller, the native turn unwinds
1618
+ // at the next safepoint, and the dispatch rejects with
1619
+ // "chat session cancelled" (routed through the catch below:
1620
+ // warm slot dropped, nothing persisted). The
1621
+ // disconnect-aware skip inside `handleNonStreaming` /
1622
+ // `endJson` remains the last line of defense for a
1623
+ // disconnect racing the final flush.
1624
+ const outcome = await runSessionNonStreaming(session, messages, config, resetNativeCache, streamSignal);
1625
+ const result = outcome.result;
1626
+ // Re-classify the `X-Session-Cache` header.
1627
+ //
1628
+ // Non-paged: a warm-slot hit that did NOT actually produce
1629
+ // native prefix reuse (`cachedTokens === 0` — e.g.
1630
+ // tokenizer change, system prompt drift squeaking past
1631
+ // the byte-equal compare via some upstream rewrite) gets
1632
+ // demoted from `prefix_hit` back to `fresh`.
1633
+ //
1634
+ // Paged: `lookup.hit` is always `false` so we entered
1635
+ // with `sessionCacheStatus = 'fresh'`. Promote to
1636
+ // `prefix_hit` when the native engine reports
1637
+ // `cachedTokens > 0` — that's the authoritative signal
1638
+ // that `BlockAllocator`'s content-addressed prefix lookup
1639
+ // recovered shared SYS blocks on this turn. `res.end` has
1640
+ // not fired yet (`handleNonStreaming` is what flushes via
1641
+ // `endJson`), so the overwrite still lands on the wire.
1642
+ if (lookup.hit && result.cachedTokens === 0) {
1643
+ sessionCacheStatus = 'fresh';
1644
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1645
+ } else if (pagedActive && result.cachedTokens > 0) {
1646
+ sessionCacheStatus = 'prefix_hit';
1647
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1648
+ }
1649
+ // Companion `X-Cached-Tokens` header: emitted only when
1650
+ // reuse genuinely happened, so operators can spot a stale
1651
+ // `prefix_hit` claim from telemetry alone.
1652
+ if (result.cachedTokens > 0) {
1653
+ res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
1654
+ }
1655
+ await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
1656
+ // Non-paged success: adopt the warm slot only when the
1657
+ // dispatch actually committed. Mirrors the streaming-side
1658
+ // dual-gate at `streamResult.ok && outcome.wasCommitted()`
1659
+ // above and the sibling `/v1/responses` adopt gate, so the
1660
+ // local invariant — "never adopt an uncommitted session"
1661
+ // — is enforced by the same check on both wire formats and
1662
+ // both endpoints. Today every native failure throws (and
1663
+ // routes through the inner catch below), so the gate is
1664
+ // dead code on the current Rust paths; it defends the
1665
+ // invariant LOCALLY so a future native change that
1666
+ // resolves `chat_session_start_sync` with
1667
+ // `Ok(finish_reason="error")` cannot silently poison the
1668
+ // warm slot. Drop on the uncommitted branch matches the
1669
+ // streaming-side `else { drop(...) }` so the sentinel does
1670
+ // not accumulate stale entries from earlier turns.
1671
+ //
1672
+ // Paged success: never adopt — block-level reuse is
1673
+ // already in the native cache, and adopting would
1674
+ // re-introduce the cross-endpoint warm-slot eviction
1675
+ // that paged is supposed to eliminate.
1676
+ if (!pagedActive) {
1677
+ if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
1678
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
1679
+ sessionRetained = true;
1680
+ } else {
1681
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1682
+ }
1683
+ }
1684
+ }
1685
+ } catch (err) {
1686
+ // A failed turn on the non-paged path must not leave a
1687
+ // poisoned warm slot for the next request to lease — drop
1688
+ // the sentinel before emitting the error response.
1689
+ // Streaming half-failures are already covered by the
1690
+ // `wasCommitted()` gate above; this catch handles
1691
+ // non-streaming throws and any pre-handler failures from
1692
+ // the streaming path. The paged path never adopts, so the
1693
+ // drop is a no-op there but kept unconditional for
1694
+ // simplicity (the registry treats `drop` of an absent key
1695
+ // as a no-op).
1696
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1697
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
1698
+ if (visibility.responseMode === null) {
1699
+ if (isContextCapacityError(err)) {
1700
+ sendAnthropicBadRequest(res, message);
1701
+ } else {
1702
+ sendAnthropicInternalError(res, message);
1703
+ }
1704
+ } else if (visibility.responseMode === 'json') {
1705
+ // Already committed to JSON — destroy the socket rather than corrupt the body.
1706
+ try {
1707
+ res.destroy(err instanceof Error ? err : new Error(message));
1708
+ } catch {
1709
+ // Socket may already be gone.
1710
+ }
1711
+ } else {
1712
+ // SSE: best-effort streaming `error`, but only if no terminal landed
1713
+ // (a double terminal would confuse the client state machine).
1714
+ if (!visibility.terminalEmitted) {
1715
+ writeFallbackErrorSSE(res, 'error', {
1716
+ error: { type: 'api_error', message },
1717
+ });
1718
+ }
1719
+ try {
1720
+ endSSE(res);
1721
+ } catch {
1722
+ // Already closed.
1723
+ }
1724
+ }
1725
+ } finally {
1726
+ // Every session that was not retained in the warm registry owns
1727
+ // request-local native state and must be released before leaving
1728
+ // the admission lane. Cleanup failure must not replace a terminal
1729
+ // response already delivered to the client.
1730
+ if (!sessionRetained) {
1731
+ try {
1732
+ await sessionReg.disposeSession(session);
1733
+ } catch (error) {
1734
+ console.error('[messages] failed to release an unretained chat-session cache owner:', error);
1735
+ }
1736
+ }
1737
+ await sessionReg.flushPendingDisposals();
1738
+ }
1739
+ });
1740
+ };
1741
+ await runInference();
1742
+ } catch (err) {
1743
+ // Admission-control rejection from the per-model queue cap
1744
+ // (`SessionRegistry.withExclusive` threw before chaining into
1745
+ // the FIFO). Emit Anthropic-shape HTTP 429 so clients back off
1746
+ // instead of silently piling up more waiters. The outer
1747
+ // `finally` below still detaches abort listeners and releases
1748
+ // the dispatch lease, so no per-request resources are leaked.
1749
+ //
1750
+ // Any other error continues to propagate so an abnormal failure
1751
+ // still routes through the handler's existing error paths.
1752
+ if (err instanceof QueueFullError) {
1753
+ if (!res.headersSent) {
1754
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
1755
+ }
1756
+ } else {
1757
+ throw err;
1758
+ }
1759
+ }
1760
+ } finally {
1761
+ // Balance the pre-dispatch admission on EVERY exit that never handed
1762
+ // the permit to `withExclusive`: binding-changed 400s, disconnects,
1763
+ // and any validation early-return inside the outer `try`. Idempotent
1764
+ // and a no-op after handoff, so the unconditional call is safe.
1765
+ preDispatchAdmission?.release();
1766
+ modelLoadAdmission?.release();
1767
+ // Drop disconnect listeners so they don't pin the request past handler
1768
+ // return. Only detach if we actually attached (gated by the flag).
1769
+ if (abortListenersAttached) {
1770
+ res.removeListener('close', onAbortClose);
1771
+ res.removeListener('error', onAbortError);
1772
+ if (abortSocket != null) {
1773
+ abortSocket.removeListener('close', onAbortClose);
1774
+ }
1775
+ if (httpReq) {
1776
+ httpReq.removeListener('close', onAbortClose);
1777
+ httpReq.removeListener('error', onAbortError);
1778
+ }
1779
+ }
1780
+ // Release against the ORIGINAL lease model — re-reading `body.model`
1781
+ // would resolve to a possibly hot-swapped binding. A concurrent
1782
+ // `unregister()` held against this lease finalises its teardown here
1783
+ // when the in-flight counter drops to zero.
1784
+ registry.releaseDispatchLease(leaseModel);
1785
+ // Belt-and-suspenders: call `finalize()` unconditionally here.
1786
+ // The eagerly-attached `finish`/`close`/`error` listeners almost
1787
+ // always win the race, but we still fire here to cover
1788
+ // pathological cases where the terminal event never arrives —
1789
+ // e.g. a synthetic mock, or a pre-dispatch early return that
1790
+ // skipped the attach entirely. `finalizeIdleRequest` is
1791
+ // idempotent (guarded by `idleRequestEnded`) so the double-fire
1792
+ // is a no-op. Detach afterwards so the listeners don't pin the
1793
+ // handler scope past return.
1794
+ finalizeIdleRequest();
1795
+ if (idleListenersAttached) {
1796
+ res.removeListener('finish', onFinalizeEvent);
1797
+ res.removeListener('close', onFinalizeEvent);
1798
+ res.removeListener('error', onFinalizeEvent);
1799
+ idleListenersAttached = false;
1800
+ }
1801
+ }
1802
+ }