@mlx-node/server 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/auth.d.ts +56 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +106 -0
  4. package/dist/chat-session-warm-reuse.d.ts +51 -0
  5. package/dist/chat-session-warm-reuse.d.ts.map +1 -0
  6. package/dist/chat-session-warm-reuse.js +72 -0
  7. package/dist/endpoints/messages-count-tokens.d.ts +8 -0
  8. package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
  9. package/dist/endpoints/messages-count-tokens.js +121 -0
  10. package/dist/endpoints/messages.d.ts +57 -5
  11. package/dist/endpoints/messages.d.ts.map +1 -1
  12. package/dist/endpoints/messages.js +1043 -147
  13. package/dist/endpoints/models.d.ts +2 -1
  14. package/dist/endpoints/models.d.ts.map +1 -1
  15. package/dist/endpoints/models.js +2 -2
  16. package/dist/endpoints/responses.d.ts +22 -7
  17. package/dist/endpoints/responses.d.ts.map +1 -1
  18. package/dist/endpoints/responses.js +608 -85
  19. package/dist/errors.d.ts +1 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -0
  22. package/dist/handler.d.ts +69 -1
  23. package/dist/handler.d.ts.map +1 -1
  24. package/dist/handler.js +70 -16
  25. package/dist/health.d.ts +146 -0
  26. package/dist/health.d.ts.map +1 -0
  27. package/dist/health.js +107 -0
  28. package/dist/host/discover.d.ts +19 -0
  29. package/dist/host/discover.d.ts.map +1 -0
  30. package/dist/host/discover.js +50 -0
  31. package/dist/host/env-policy.d.ts +62 -0
  32. package/dist/host/env-policy.d.ts.map +1 -0
  33. package/dist/host/env-policy.js +69 -0
  34. package/dist/host/index.d.ts +202 -0
  35. package/dist/host/index.d.ts.map +1 -0
  36. package/dist/host/index.js +325 -0
  37. package/dist/host/logger.d.ts +36 -0
  38. package/dist/host/logger.d.ts.map +1 -0
  39. package/dist/host/logger.js +376 -0
  40. package/dist/host/net.d.ts +65 -0
  41. package/dist/host/net.d.ts.map +1 -0
  42. package/dist/host/net.js +97 -0
  43. package/dist/host/paths.d.ts +28 -0
  44. package/dist/host/paths.d.ts.map +1 -0
  45. package/dist/host/paths.js +71 -0
  46. package/dist/host/swap.d.ts +27 -0
  47. package/dist/host/swap.d.ts.map +1 -0
  48. package/dist/host/swap.js +178 -0
  49. package/dist/host/temp-root.d.ts +57 -0
  50. package/dist/host/temp-root.d.ts.map +1 -0
  51. package/dist/host/temp-root.js +99 -0
  52. package/dist/idle-sweeper.d.ts +245 -0
  53. package/dist/idle-sweeper.d.ts.map +1 -0
  54. package/dist/idle-sweeper.js +408 -0
  55. package/dist/index.d.ts +19 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +17 -1
  58. package/dist/load-model.d.ts +69 -0
  59. package/dist/load-model.d.ts.map +1 -0
  60. package/dist/load-model.js +63 -0
  61. package/dist/mappers/anthropic-request.d.ts +24 -2
  62. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  63. package/dist/mappers/anthropic-request.js +222 -24
  64. package/dist/mappers/anthropic-response.d.ts +29 -4
  65. package/dist/mappers/anthropic-response.d.ts.map +1 -1
  66. package/dist/mappers/anthropic-response.js +143 -21
  67. package/dist/mappers/request.d.ts +48 -0
  68. package/dist/mappers/request.d.ts.map +1 -1
  69. package/dist/mappers/request.js +211 -35
  70. package/dist/mappers/response.d.ts.map +1 -1
  71. package/dist/mappers/response.js +13 -1
  72. package/dist/model-work-coordinator.d.ts +95 -0
  73. package/dist/model-work-coordinator.d.ts.map +1 -0
  74. package/dist/model-work-coordinator.js +201 -0
  75. package/dist/pending-writes.d.ts.map +1 -1
  76. package/dist/presets.d.ts +82 -0
  77. package/dist/presets.d.ts.map +1 -0
  78. package/dist/presets.js +98 -0
  79. package/dist/registry.d.ts +31 -1
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +33 -5
  82. package/dist/router.d.ts +37 -1
  83. package/dist/router.d.ts.map +1 -1
  84. package/dist/router.js +79 -7
  85. package/dist/server.d.ts +193 -3
  86. package/dist/server.d.ts.map +1 -1
  87. package/dist/server.js +173 -10
  88. package/dist/session-registry.d.ts +279 -18
  89. package/dist/session-registry.d.ts.map +1 -1
  90. package/dist/session-registry.js +518 -37
  91. package/dist/stop-sequence-buffer.d.ts +58 -0
  92. package/dist/stop-sequence-buffer.d.ts.map +1 -0
  93. package/dist/stop-sequence-buffer.js +148 -0
  94. package/dist/streaming.d.ts +14 -0
  95. package/dist/streaming.d.ts.map +1 -1
  96. package/dist/streaming.js +45 -0
  97. package/dist/text-recovery.d.ts +35 -0
  98. package/dist/text-recovery.d.ts.map +1 -0
  99. package/dist/text-recovery.js +41 -0
  100. package/dist/timing.d.ts +80 -0
  101. package/dist/timing.d.ts.map +1 -0
  102. package/dist/timing.js +121 -0
  103. package/dist/tool-call-buffer.d.ts +5 -5
  104. package/dist/tool-call-buffer.d.ts.map +1 -1
  105. package/dist/tool-call-buffer.js +28 -8
  106. package/dist/types-anthropic.d.ts +161 -1
  107. package/dist/types-anthropic.d.ts.map +1 -1
  108. package/dist/types.d.ts +172 -2
  109. package/dist/types.d.ts.map +1 -1
  110. package/package.json +15 -3
@@ -1,24 +1,202 @@
1
1
  /**
2
2
  * POST /v1/messages — stateless Anthropic Messages API.
3
3
  *
4
- * Every request carries the full conversation in `req.messages`. We allocate
5
- * a fresh `ChatSession` per request via `SessionRegistry.getOrCreate(null)`,
6
- * prime with the mapped history, and run `startFromHistory[Stream]`. No
7
- * adopt/drop: the session's lifetime is this single call.
4
+ * Every request carries the full conversation in `req.messages`. The
5
+ * Anthropic Messages API is stateless on the wire: there is no
6
+ * `previous_response_id` to thread, and clients (e.g. Claude Code)
7
+ * also do NOT propagate `prompt_cache_key` back to the server. The
8
+ * cross-turn / cross-conversation prefix-reuse path is one of two
9
+ * mutually-exclusive mechanisms, picked at request time based on
10
+ * whether the underlying native model has the block-paged KV cache
11
+ * adapter active (`SessionCapableModel.hasBlockPagedCache?.()`):
12
+ *
13
+ * * **Paged-active path** (Qwen3 + LFM2 + Gemma4 are paged-active
14
+ * today; Qwen3.5 dense/MoE and Qianfan-OCR remain non-paged /
15
+ * default-off pending a perf decision and adapter wiring
16
+ * respectively). Each request allocates a fresh `ChatSession` via
17
+ * `SessionRegistry.createFreshSession()` and runs a full
18
+ * `session.reset()` + `primeHistory()` +
19
+ * `startFromHistory[Stream]()`. The JS-side warm slot is
20
+ * **not** consulted, **not** leased, and **not** adopted —
21
+ * cross-request prefix reuse is handled entirely by the native
22
+ * `BlockAllocator`'s content-addressed prefix-hash table, which
23
+ * refcounts SYS blocks shared across requests transparently
24
+ * (two parallel `/v1/messages` requests with the same system
25
+ * prompt run on distinct `ChatSession` objects but reference
26
+ * the same physical KV blocks). The non-streaming
27
+ * `X-Session-Cache` header is promoted from `fresh` to
28
+ * `prefix_hit` after dispatch when the engine reports
29
+ * `cachedTokens > 0`.
30
+ *
31
+ * * **Non-paged path** (Qwen3.5 dense + MoE — default-off pending a
32
+ * perf decision; the Qianfan-OCR VLM — no adapter wired). Each
33
+ * request looks up the warm slot via
34
+ * `SessionRegistry.getOrCreateWarmAny(requestedSystem)`. On a
35
+ * HIT we keep the underlying native KV cache alive
36
+ * (`resetPreservingNativeCacheForWarmReuse` wipes only JS-side
37
+ * session state) so the native `verify_cache_prefix_direct` can
38
+ * recognize the cached prefix and re-prefill only the new
39
+ * suffix. On a MISS we run a full `session.reset()` to wipe
40
+ * both JS and native state — a fresh JS session does NOT imply
41
+ * a fresh native cache (the underlying `SessionCapableModel` is
42
+ * shared and its native `cached_token_history` persists across
43
+ * requests). After the dispatch settles we adopt the session
44
+ * back under the sentinel id `'__msg_warm__'` (or drop on
45
+ * uncommitted streams / thrown errors) so the next turn can
46
+ * lease it. The sentinel is never produced by either the OpenAI
47
+ * or the Anthropic wire format, so cross-endpoint capture via
48
+ * tier-1 is impossible by construction. The `/v1/responses` and
49
+ * `/v1/messages` endpoints still SHARE the single warm slot
50
+ * under the registry's single-warm invariant on this path — a
51
+ * turn on one side can evict the other's slot.
52
+ *
53
+ * The `prompt_cache_key` request field is still NOT exposed on this
54
+ * endpoint. Cross-conversation block-level cache reuse on the
55
+ * paged path is now driven by native content-addressing instead of
56
+ * the JS warm slot, so adding the field is no longer a prerequisite
57
+ * for that use case.
8
58
  */
59
+ import { isContextCapacityError } from '@mlx-node/lm';
60
+ import { resetPreservingNativeCacheForWarmReuse } from '../chat-session-warm-reuse.js';
9
61
  import { sendAnthropicBadRequest, sendAnthropicInternalError, sendAnthropicNotFound, sendAnthropicRateLimit, } from '../errors.js';
10
- import { mapAnthropicRequest } from '../mappers/anthropic-request.js';
11
- import { buildAnthropicResponse, buildContentBlockDelta, buildContentBlockStart, buildContentBlockStop, buildMessageDelta, buildMessageStartEvent, buildMessageStop, mapStopReason, } from '../mappers/anthropic-response.js';
62
+ import { canonicalizeSystemForCacheKey, mapAnthropicRequest } from '../mappers/anthropic-request.js';
63
+ import { buildAnthropicResponse, buildContentBlockDelta, buildContentBlockStart, buildContentBlockStop, buildMessageDelta, buildMessageStartEvent, buildMessageStop, containsToolCallMarkup, internalToolCallIdToAnthropic, recoverSuppressedToolCallText, mapStopReason, } from '../mappers/anthropic-response.js';
12
64
  import { genId } from '../mappers/response.js';
13
65
  import { QueueFullError } from '../session-registry.js';
66
+ import { StopSequenceBuffer } from '../stop-sequence-buffer.js';
14
67
  import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
68
+ import { longestSuffixPrefixOverlap } from '../text-recovery.js';
69
+ import { resolveServerTuningForUsage } from '../timing.js';
15
70
  import { ToolCallTagBuffer } from '../tool-call-buffer.js';
16
71
  import { createVisibility, endJson, flushTerminalSSE, markSSEMode, writeFallbackErrorSSE, } from '../transport-visibility.js';
17
- import { validateAndCanonicalizeHistoryToolOrder } from './responses.js';
72
+ import { MAX_OUTPUT_TOKENS, validateAndCanonicalizeHistoryToolOrder } from './responses.js';
73
+ /**
74
+ * Sentinel response id used to adopt and drop the per-model warm slot
75
+ * for `/v1/messages` reuse. The Anthropic Messages API does not
76
+ * produce a `previous_response_id` clients could echo back, and the
77
+ * OpenAI `/v1/responses` side mints fresh `resp_*` ids — so this
78
+ * literal can never collide with a tier-1 lookup from either
79
+ * endpoint. Centralised here to keep the four call sites
80
+ * (`adopt` on success, `drop` on failure, both for streaming and
81
+ * non-streaming) in lockstep.
82
+ */
83
+ const MESSAGES_WARM_SLOT_ID = '__msg_warm__';
84
+ const CLAUDE_CODE_TITLE_MAX_TOKENS = 128;
85
+ function withAdmissionControlledInference(sessionReg, modelWorkCoordinator, fn) {
86
+ return sessionReg.withExclusive(() => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn()));
87
+ }
88
+ function requestAllowsToolUse(body) {
89
+ return Array.isArray(body.tools) && body.tools.length > 0;
90
+ }
91
+ function hasSuppressedToolCalls(result, body) {
92
+ return !requestAllowsToolUse(body) && result.toolCalls.some((t) => t.status === 'ok');
93
+ }
94
+ function applyOutputTokenLimit(config, limit) {
95
+ if (limit == null ||
96
+ !Number.isFinite(limit) ||
97
+ limit <= 0 ||
98
+ config.maxNewTokens == null ||
99
+ config.maxNewTokens <= limit) {
100
+ return config;
101
+ }
102
+ return { ...config, maxNewTokens: Math.floor(limit) };
103
+ }
104
+ function systemText(system) {
105
+ if (system == null)
106
+ return '';
107
+ if (typeof system === 'string')
108
+ return system;
109
+ return system
110
+ .filter((block) => block.type === 'text')
111
+ .map((block) => block.text)
112
+ .join('\n');
113
+ }
114
+ function hasTitleJsonSchema(schema) {
115
+ if (schema == null || typeof schema !== 'object')
116
+ return false;
117
+ const obj = schema;
118
+ if (obj.type !== 'object')
119
+ return false;
120
+ if (obj.properties == null || typeof obj.properties !== 'object')
121
+ return false;
122
+ const properties = obj.properties;
123
+ const title = properties['title'];
124
+ if (title == null || typeof title !== 'object')
125
+ return false;
126
+ if (title.type !== 'string')
127
+ return false;
128
+ return Array.isArray(obj.required) && obj.required.includes('title');
129
+ }
130
+ function isClaudeCodeTitleGenerationRequest(body) {
131
+ if (requestAllowsToolUse(body))
132
+ return false;
133
+ const format = body.output_config?.format;
134
+ if (format?.type !== 'json_schema' || !hasTitleJsonSchema(format.schema))
135
+ return false;
136
+ const prompt = systemText(body.system).toLowerCase();
137
+ return (prompt.includes('generate a concise') &&
138
+ prompt.includes('title') &&
139
+ prompt.includes('return json') &&
140
+ prompt.includes('"title"'));
141
+ }
142
+ function applyClaudeCodeTitleFastPath(config, body) {
143
+ if (!isClaudeCodeTitleGenerationRequest(body))
144
+ return config;
145
+ const cappedMax = config.maxNewTokens == null
146
+ ? CLAUDE_CODE_TITLE_MAX_TOKENS
147
+ : Math.min(config.maxNewTokens, CLAUDE_CODE_TITLE_MAX_TOKENS);
148
+ return {
149
+ ...config,
150
+ maxNewTokens: cappedMax,
151
+ reasoningEffort: 'none',
152
+ thinkingTokenBudget: 0,
153
+ includeReasoning: false,
154
+ };
155
+ }
18
156
  // Non-streaming path
19
- async function handleNonStreaming(res, result, body, visibility) {
157
+ async function handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming) {
20
158
  const messageId = genId('msg_');
21
- const response = buildAnthropicResponse(result, body, messageId);
159
+ // Honor client-supplied `stop_sequences`: scan the SAME visible text the
160
+ // response builder will emit for the earliest configured stop string. When
161
+ // the request disallows tools but the parser still produced a tool call and
162
+ // `result.text` is empty, `buildAnthropicContent` emits the recovered
163
+ // suppressed-tool text — so the scan must mirror that recovery gate and run
164
+ // over the recovered text, not the empty `result.text`. The scan does
165
+ // push+flush so a complete stop that `push()` held back (a longer
166
+ // overlapping stop was still viable) is resolved at end-of-text, matching
167
+ // the streaming done-path. On a match we truncate the text the response is
168
+ // built from at the match (dropping the stop string and everything after it)
169
+ // and report `stop_reason: 'stop_sequence'` + `stop_sequence: '<matched>'`;
170
+ // `buildAnthropicResponse` then suppresses tool calls and the recovery
171
+ // branch and emits the truncated text verbatim. The native `ChatResult` is
172
+ // left untouched. With no match `responseResult` stays `result` (full text
173
+ // retained — `flush()` releases any held incomplete partial as normal text),
174
+ // so behavior is byte-identical to a request without `stop_sequences`.
175
+ const visibleText = !requestAllowsToolUse(body) &&
176
+ result.text.length === 0 &&
177
+ result.toolCalls.filter((t) => t.status === 'ok').length > 0 &&
178
+ containsToolCallMarkup(result.rawText)
179
+ ? recoverSuppressedToolCallText(result.rawText)
180
+ : result.text;
181
+ let matchedStopSequence = null;
182
+ let responseResult = result;
183
+ if (stopSequences.length > 0) {
184
+ const stopBuffer = new StopSequenceBuffer(stopSequences);
185
+ const pushed = stopBuffer.push(visibleText);
186
+ const flushed = stopBuffer.flush();
187
+ const matched = pushed.matched ?? flushed.matched;
188
+ if (matched !== null) {
189
+ matchedStopSequence = matched;
190
+ responseResult = { ...result, text: pushed.safeText + flushed.safeText };
191
+ }
192
+ }
193
+ // `result.performance` is only populated when `reportPerformance: true`
194
+ // rides on the underlying `ChatConfig`; otherwise the field is
195
+ // `undefined` and the mapper elides the wire-extension fields. The
196
+ // launcher wires the flag on for verbose-log builds and leaves it off
197
+ // by default, matching how `cachedTokens` is treated through
198
+ // `buildAnthropicResponse`.
199
+ const response = buildAnthropicResponse(responseResult, body, messageId, result.performance, requestAllowsToolUse(body), serverTiming, matchedStopSequence);
22
200
  // Native `chatSession*` has no AbortSignal surface yet, so a client that
23
201
  // disconnects mid-decode still burns every remaining token under the
24
202
  // per-model mutex. Disconnect handling is delegated to `endJson`'s
@@ -26,9 +204,12 @@ async function handleNonStreaming(res, result, body, visibility) {
26
204
  // has been committed to 'json' — the outer catch then destroys the socket.
27
205
  await endJson(res, JSON.stringify(response), visibility);
28
206
  }
29
- // Streaming path
30
- async function handleStreamingNative(res, chatStream, body, wasCommitted, httpReq, visibility) {
207
+ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpReq, visibility, emitReasoning, stopSequences, serverTiming) {
31
208
  const messageId = genId('msg_');
209
+ // `runSessionStreaming` completed the exact token/capacity preflight before
210
+ // handing us this iterator. Commit SSE immediately instead of entering the
211
+ // generator here: its first `next()` also starts image processing/prefill and
212
+ // may not resolve until the first generated token.
32
213
  beginSSE(res);
33
214
  // Commit SSE wire format now so any throw before the terminal event routes
34
215
  // to the streaming error epilogue instead of corrupting the JSON path.
@@ -38,7 +219,38 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
38
219
  let hasEmittedThinking = false;
39
220
  let hasEmittedText = false;
40
221
  let emittedTextLength = 0;
222
+ // Whitespace-only text seen before any non-whitespace content is buffered
223
+ // here so we don't open a text content block that the client would have
224
+ // to render as a stray `"\n\n"` immediately before a tool_use block.
225
+ // Flushed lazily when the first non-whitespace text delta arrives;
226
+ // dropped silently when a non-text block (tool_use) is about to open or
227
+ // when the stream ends without further text. Once `hasEmittedText` flips
228
+ // true (a real text block exists) this buffer is no longer consulted —
229
+ // subsequent whitespace-only deltas pass through to keep streamed text
230
+ // byte-accurate.
231
+ let pendingLeadingWhitespace = '';
232
+ // Mirror of the actual streamed text body, used by the malformed-tool-call
233
+ // recovery branches below. `emittedTextLength` counts bytes of streamed text
234
+ // — but `event.text` on the terminal `done` chunk is the post-</think>-trim
235
+ // cleaned text from the native `split_at_think_end`, so streamed and final
236
+ // prefixes can diverge (e.g. streamed=`"\n\n<tool_call>..."`,
237
+ // finalText=`"<tool_call>..."`). The recovery branches use
238
+ // `longestSuffixPrefixOverlap(emittedText, finalText)` to find the unsent
239
+ // suffix instead of a length-based slice that would chop characters.
240
+ let emittedText = '';
41
241
  const tagBuffer = new ToolCallTagBuffer();
242
+ // Client-supplied `stop_sequences` detector. Feeds on the visible text that
243
+ // survives `tagBuffer` (structural-marker stripping) so it never sees tool
244
+ // markup. An empty `stopSequences` constructs a pass-through buffer
245
+ // (`push` returns its input verbatim, `flush` returns ''), so the wire is
246
+ // byte-identical to a request without `stop_sequences`. When a stop string
247
+ // matches, `matchedStopSequence` is recorded, all later visible text is
248
+ // suppressed, and the done-path emits nothing past the stop. The done-path
249
+ // also scans the terminal / recovered visible text on this SAME buffer (with
250
+ // any held partial still in place), so a stop straddling the stream/terminal
251
+ // boundary is caught with buffer continuity.
252
+ const stopBuffer = new StopSequenceBuffer(stopSequences);
253
+ let matchedStopSequence = null;
42
254
  // Terminal emission is deferred until after the loop drains so `wasCommitted()`
43
255
  // reads an authoritative `session.turns`. On a committed done chunk we emit
44
256
  // `message_delta` + `message_stop`; on an uncommitted terminal (finishReason=error,
@@ -48,7 +260,25 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
48
260
  let terminalStopReason = null;
49
261
  let terminalNumTokens = 0;
50
262
  let terminalPromptTokens;
263
+ // Captured from the terminal `done` chunk so the success-branch
264
+ // `buildMessageDelta` can emit Anthropic-spec cache accounting
265
+ // (`cache_read_input_tokens` + reduced `input_tokens`) on warm
266
+ // hits. Stays `undefined` on streams whose terminal chunk omits
267
+ // the field — mocks and any future in-process driver that hasn't
268
+ // adopted the surface — so `buildMessageDelta` falls back to the
269
+ // pre-Round-6 behaviour.
270
+ let terminalCachedTokens;
271
+ // Captured from the terminal `done` chunk so the success-branch
272
+ // `buildMessageDelta` can attach the server-extension perf fields
273
+ // (`time_to_first_token_ms`, `prefill_tokens_per_second`,
274
+ // `decode_tokens_per_second`). Stays `undefined` when the underlying
275
+ // dispatch did not opt into performance reporting (or when a mock
276
+ // bridge omits the field) — the mapper elides the fields rather
277
+ // than emitting zeros.
278
+ let terminalPerformance;
51
279
  let terminalErrorMessage = null;
280
+ const allowToolUse = requestAllowsToolUse(body);
281
+ let suppressedToolCalls = false;
52
282
  // `thrownError` sticks on a generator throw; `clientAborted` sticks on
53
283
  // HTTP `close`/`error` on req, res, or res.socket. Either one routes the
54
284
  // post-loop block to the failure epilogue. Native decode has no
@@ -91,67 +321,156 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
91
321
  terminalErrorMessage = 'model reported finishReason=error';
92
322
  break;
93
323
  }
94
- const remainingText = tagBuffer.flush();
95
- if (!tagBuffer.suppressed && remainingText) {
96
- if (!hasEmittedText) {
97
- if (hasEmittedThinking) {
98
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
99
- }
100
- hasEmittedText = true;
101
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
102
- }
103
- emittedTextLength += remainingText.length;
104
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: remainingText }));
105
- }
106
- if (hasEmittedThinking && !hasEmittedText) {
107
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
324
+ // Flush the tag buffer's residual but keep the stop buffer intact: it
325
+ // may still hold a partial from the streamed deltas, and a stop can
326
+ // straddle the boundary between that held partial, the tag residue, and
327
+ // the native terminal/recovered text. All of it runs through the SAME
328
+ // buffer, in stream order, with a single flush, BEFORE the stop match,
329
+ // the emitted text, and the tool decision are finalized.
330
+ const tagResidual = tagBuffer.flush();
331
+ const heldPartial = stopBuffer.pending;
332
+ const parsedToolCalls = event.toolCalls.filter((t) => t.status === 'ok');
333
+ if (!allowToolUse && parsedToolCalls.length > 0) {
334
+ suppressedToolCalls = true;
108
335
  }
109
- const finalText = event.text;
110
- const okToolCalls = event.toolCalls.filter((t) => t.status === 'ok');
111
- const hasToolCalls = okToolCalls.length > 0;
112
- // Recovery: suppression triggered but no tool calls parsed — emit final text as a text block.
113
- if (tagBuffer.suppressed && !hasToolCalls && finalText && !hasEmittedText) {
114
- // Thinking block (if any) was already closed above.
115
- hasEmittedText = true;
116
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
117
- emittedTextLength += finalText.length;
118
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: finalText }));
336
+ const finalText = !allowToolUse &&
337
+ event.text.length === 0 &&
338
+ parsedToolCalls.length > 0 &&
339
+ containsToolCallMarkup(event.rawText)
340
+ ? recoverSuppressedToolCallText(event.rawText)
341
+ : event.text;
342
+ // The visible text the stream already RECEIVED, in stream order: what
343
+ // reached the wire (`emittedText`), the parked leading whitespace the
344
+ // detector already cleared but no block has shown yet
345
+ // (`pendingLeadingWhitespace`), the still-held detector partial
346
+ // (`heldPartial`), and the tag residue about to be scanned
347
+ // (`tagResidual`). Every parked/held byte is counted exactly once so the
348
+ // recovered terminal text is only the suffix of `finalText` the stream
349
+ // has not already accounted for — omitting the parked whitespace would
350
+ // make a full-text `finalText` look entirely unsent and replay the
351
+ // already-received prefix.
352
+ const streamedReceived = emittedText + pendingLeadingWhitespace + heldPartial + tagResidual;
353
+ let recoveredTail = '';
354
+ if (finalText) {
355
+ if (!hasEmittedText && heldPartial.length === 0 && tagResidual.length === 0) {
356
+ // Nothing was streamed or held: the whole `finalText` is terminal.
357
+ recoveredTail = finalText;
358
+ }
359
+ else if (!streamedReceived.includes(finalText)) {
360
+ // `finalText` extends past what the stream produced: recover the
361
+ // suffix beyond the longest overlap. The `includes` guard skips the
362
+ // duplicate-trim case where `finalText` is a substring of the
363
+ // received text (native `.trim()` / post-`</think>` shrinkage).
364
+ recoveredTail = finalText.slice(longestSuffixPrefixOverlap(streamedReceived, finalText));
365
+ }
119
366
  }
120
- else if (tagBuffer.suppressed && !hasToolCalls && finalText && hasEmittedText) {
121
- // Recovery: streaming text was cut off by a false-alarm `<tool_call>` tag. Emit the unsent suffix.
122
- const unsent = finalText.slice(emittedTextLength);
123
- if (unsent) {
124
- emittedTextLength += unsent.length;
125
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: unsent }));
367
+ // One continuous scan held partial (already buffered) + tag residue +
368
+ // recovered tail, in stream order, with a single flush at the end. A
369
+ // stop matched anywhere here is caught with buffer continuity, and
370
+ // `matchedStopSequence` is finalized before tool emission and
371
+ // `stop_reason`. With an empty `stopSequences` the buffer is a
372
+ // pass-through, so `terminalVisible` equals the released text verbatim.
373
+ let terminalVisible = '';
374
+ for (const segment of [tagResidual, recoveredTail]) {
375
+ const pushed = stopBuffer.push(segment);
376
+ if (pushed.matched !== null) {
377
+ matchedStopSequence = pushed.matched;
126
378
  }
379
+ terminalVisible += pushed.safeText;
380
+ }
381
+ const flushed = stopBuffer.flush();
382
+ if (flushed.matched !== null) {
383
+ matchedStopSequence = flushed.matched;
127
384
  }
128
- // Emit any unsent suffix when final text is longer than what was streamed.
129
- if (hasEmittedText && finalText && finalText.length > emittedTextLength) {
130
- const unsent = finalText.slice(emittedTextLength);
131
- emittedTextLength += unsent.length;
132
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: unsent }));
385
+ terminalVisible += flushed.safeText;
386
+ // Parked leading whitespace belongs in front of RELEASED held content
387
+ // (a stop-buffer partial or tag residue). When the terminal text is
388
+ // purely native `finalText` recovery, `finalText` already carries that
389
+ // whitespace, so prepending it would double those bytes.
390
+ const prependParked = heldPartial.length > 0 || tagResidual.length > 0;
391
+ // Close a dangling reasoning block before any terminal text block opens.
392
+ if (hasEmittedThinking && !hasEmittedText) {
393
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
133
394
  }
134
395
  if (hasEmittedText) {
396
+ // A text block is already open from the streamed deltas: append the
397
+ // newly released terminal text (if any), then close the block.
398
+ if (terminalVisible) {
399
+ emittedText += terminalVisible;
400
+ emittedTextLength += terminalVisible.length;
401
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
402
+ type: 'text_delta',
403
+ text: terminalVisible,
404
+ }));
405
+ }
135
406
  writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
136
407
  contentBlockIndex++;
408
+ pendingLeadingWhitespace = '';
137
409
  }
138
- else if (!finalText && hasToolCalls) {
139
- // Pure tool-call turn no text block.
140
- }
141
- else if (finalText) {
142
- // All text arrived in the final event; emit it as a single block.
143
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
144
- emittedTextLength += finalText.length;
145
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: finalText }));
146
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
147
- contentBlockIndex++;
410
+ else {
411
+ // No text block open yet. Build the block body from the terminal text
412
+ // (fronted by parked whitespace only when it precedes released held
413
+ // content), or from the parked whitespace alone when a stop truncated
414
+ // the turn right after it.
415
+ const body = terminalVisible
416
+ ? prependParked
417
+ ? pendingLeadingWhitespace + terminalVisible
418
+ : terminalVisible
419
+ : matchedStopSequence !== null
420
+ ? pendingLeadingWhitespace
421
+ : '';
422
+ pendingLeadingWhitespace = '';
423
+ // Open a text block for non-whitespace content, for a stop-truncated
424
+ // prefix (so the streamed body equals the non-streaming one), or for
425
+ // pure native `finalText` recovery (mirrors emitting recovered text
426
+ // verbatim). A stop-matched turn always opens a text block — empty if
427
+ // the stop consumed all visible output — so the reconstructed content
428
+ // matches the non-streaming `[{type:'text', text:''}]`. Whitespace-only
429
+ // released held content opens no block.
430
+ const openTextBlock = matchedStopSequence !== null || (body.length > 0 && (body.trim().length > 0 || !prependParked));
431
+ if (openTextBlock) {
432
+ hasEmittedText = true;
433
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
434
+ type: 'text',
435
+ text: '',
436
+ }));
437
+ if (body.length > 0) {
438
+ emittedText += body;
439
+ emittedTextLength += body.length;
440
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
441
+ type: 'text_delta',
442
+ text: body,
443
+ }));
444
+ }
445
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
446
+ contentBlockIndex++;
447
+ }
148
448
  }
449
+ // Decide tool emission only AFTER the full terminal stop scan: when a
450
+ // stop matched ANYWHERE (the pre-tool visible text OR the
451
+ // terminal/recovered text) the tool calls are suppressed so a streamed
452
+ // turn never carries both a tool_use block and
453
+ // `stop_reason: 'stop_sequence'`. This keeps streaming in lockstep with
454
+ // the non-streaming path (`buildAnthropicResponse`).
455
+ const okToolCalls = allowToolUse && matchedStopSequence === null ? parsedToolCalls : [];
456
+ const hasToolCalls = okToolCalls.length > 0;
149
457
  for (const tc of okToolCalls) {
150
- const toolId = tc.id ?? genId('toolu_');
458
+ // Translate native `call_<uuid>` ids (minted by the Rust parser,
459
+ // which keeps the OpenAI Responses convention) into the
460
+ // Anthropic-spec `toolu_<uuid>` shape at the wire boundary.
461
+ // The `genId('toolu_')` fallback covers the case where the
462
+ // native side did not populate an id (an in-process driver or
463
+ // a legacy bridge).
464
+ const toolId = tc.id != null ? internalToolCallIdToAnthropic(tc.id) : genId('toolu_');
151
465
  const parsedInput = typeof tc.arguments === 'string'
152
466
  ? JSON.parse(tc.arguments)
153
467
  : tc.arguments;
154
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'tool_use', id: toolId, name: tc.name, input: {} }));
468
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
469
+ type: 'tool_use',
470
+ id: toolId,
471
+ name: tc.name,
472
+ input: {},
473
+ }));
155
474
  writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
156
475
  type: 'input_json_delta',
157
476
  partial_json: JSON.stringify(parsedInput),
@@ -162,49 +481,151 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
162
481
  // Capture terminal state and break — actual `message_delta` / `message_stop` /
163
482
  // `error` emission is deferred until after the loop so `wasCommitted()` reads
164
483
  // an authoritative `session.turns` (the producer's finally runs on break).
165
- terminalStopReason = mapStopReason(event.finishReason, hasToolCalls);
484
+ terminalStopReason = mapStopReason(event.finishReason, hasToolCalls, matchedStopSequence);
166
485
  terminalNumTokens = event.numTokens;
167
486
  terminalPromptTokens = event.promptTokens;
487
+ terminalCachedTokens = event.cachedTokens;
488
+ terminalPerformance = event.performance;
168
489
  break;
169
490
  }
170
491
  // Delta event
171
492
  if (event.isReasoning) {
493
+ if (!emitReasoning)
494
+ continue;
172
495
  const deltaText = event.text.replace(/<\/think>/g, '');
173
496
  if (!deltaText)
174
497
  continue;
175
498
  if (!hasEmittedThinking) {
176
499
  hasEmittedThinking = true;
177
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'thinking', thinking: '' }));
500
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
501
+ type: 'thinking',
502
+ thinking: '',
503
+ }));
178
504
  contentBlockIndex++;
179
505
  }
180
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex - 1, { type: 'thinking_delta', thinking: deltaText }));
506
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex - 1, {
507
+ type: 'thinking_delta',
508
+ thinking: deltaText,
509
+ }));
181
510
  }
182
511
  else {
183
- // Text delta with `<tool_call>` buffering.
512
+ // Text delta with structural-marker buffering. Even when the
513
+ // request did not advertise tools, model-side tool/channel/turn
514
+ // markers are transport structure, not user-visible text.
184
515
  const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
185
516
  if (tagFound) {
186
- if (cleanPrefix.trim()) {
517
+ // A structural tag (`<tool_call>` etc.) follows, so the visible
518
+ // text before it terminates here. Only `cleanPrefix` is fresh
519
+ // model text — route it through the stop-sequence detector so a
520
+ // configured stop string landing in it (e.g. "...HALT " right
521
+ // before a `<tool_call>`) is honored, not leaked. Do NOT flush the
522
+ // detector here: a held partial (e.g. "HA" of "HALT") must stay
523
+ // buffered, because the native cleaned done text can reconstitute
524
+ // the bytes that followed the suppressed tag and complete the stop
525
+ // across that boundary. The done-path scans the held partial
526
+ // together with the terminal/recovered text and resolves it —
527
+ // releasing it as visible text if it cannot complete, or suppressing
528
+ // it if it does. `pendingLeadingWhitespace` is whitespace the
529
+ // detector already cleared on an earlier delta (held back only
530
+ // because no text block was open yet), so it is prepended OUTSIDE
531
+ // the buffer: re-pushing it would double-scan it AND, because the
532
+ // buffer queues it after any held partial, invert stream order
533
+ // (e.g. held "H" + buffered " " -> "H ") or forge a false match. On
534
+ // a match `matchedStopSequence` is recorded so the terminal reports
535
+ // `stop_sequence`. With an empty `stopSequences` the detector is a
536
+ // pass-through, so `visibleText === pendingLeadingWhitespace +
537
+ // cleanPrefix` and the wire is byte-identical to today.
538
+ const stopPushed = stopBuffer.push(cleanPrefix);
539
+ if (stopPushed.matched !== null) {
540
+ matchedStopSequence = stopPushed.matched;
541
+ }
542
+ const visibleText = pendingLeadingWhitespace + stopPushed.safeText;
543
+ // Mirror the original `cleanPrefix.trim()` gate, now on the
544
+ // detector's safe text: emit only when there is non-whitespace to
545
+ // show, so a pure-whitespace prefix never ratifies a stray
546
+ // whitespace-only text block before the tool_use frame. When the
547
+ // safe text is whitespace-only (e.g. the detector is still holding a
548
+ // partial), KEEP it parked so the done-path can join it with
549
+ // whatever the held partial releases — clearing it here would drop
550
+ // it before that text block opens.
551
+ if (visibleText.trim().length > 0) {
187
552
  if (!hasEmittedText) {
188
553
  if (hasEmittedThinking) {
189
554
  writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
190
555
  }
191
556
  hasEmittedText = true;
192
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
557
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
558
+ type: 'text',
559
+ text: '',
560
+ }));
193
561
  }
194
- emittedTextLength += cleanPrefix.length;
195
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: cleanPrefix }));
562
+ pendingLeadingWhitespace = '';
563
+ emittedText += visibleText;
564
+ emittedTextLength += visibleText.length;
565
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
566
+ type: 'text_delta',
567
+ text: visibleText,
568
+ }));
569
+ }
570
+ else {
571
+ pendingLeadingWhitespace = hasEmittedText ? '' : visibleText;
196
572
  }
197
573
  }
198
574
  else if (safeText) {
199
- if (!hasEmittedText) {
200
- if (hasEmittedThinking) {
201
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
575
+ // Run the tag-buffer's visible text through the stop-sequence
576
+ // detector. `visibleText` is what survives suppression: the buffer
577
+ // holds back a trailing suffix that could be the start of a stop
578
+ // string (released on a later push or at flush), and once a full
579
+ // stop string matches it returns empty `safeText` for the rest of
580
+ // the stream. We record the match and keep consuming so the native
581
+ // `done` chunk still fires the commit gate and history commit.
582
+ const stopResult = stopBuffer.push(safeText);
583
+ if (stopResult.matched !== null) {
584
+ matchedStopSequence = stopResult.matched;
585
+ }
586
+ const visibleText = stopResult.safeText;
587
+ if (visibleText) {
588
+ if (!hasEmittedText) {
589
+ // Hold back leading whitespace-only text so a `\n\n` emitted
590
+ // right before a `<tool_call>` tag never gets ratified into a
591
+ // standalone text content block. We can't open the block now
592
+ // because we don't yet know whether the next event is a real
593
+ // text delta (in which case the buffered prefix is flushed
594
+ // together with it) or a structural tag (in which case the
595
+ // buffer is dropped silently at tag-found / done time). When
596
+ // any non-whitespace arrives we ratify the block exactly
597
+ // once with `pendingLeadingWhitespace + visibleText`.
598
+ const combined = pendingLeadingWhitespace + visibleText;
599
+ if (combined.trim().length === 0) {
600
+ pendingLeadingWhitespace = combined;
601
+ }
602
+ else {
603
+ if (hasEmittedThinking) {
604
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
605
+ }
606
+ hasEmittedText = true;
607
+ writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
608
+ type: 'text',
609
+ text: '',
610
+ }));
611
+ pendingLeadingWhitespace = '';
612
+ emittedText += combined;
613
+ emittedTextLength += combined.length;
614
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
615
+ type: 'text_delta',
616
+ text: combined,
617
+ }));
618
+ }
619
+ }
620
+ else {
621
+ emittedText += visibleText;
622
+ emittedTextLength += visibleText.length;
623
+ writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, {
624
+ type: 'text_delta',
625
+ text: visibleText,
626
+ }));
202
627
  }
203
- hasEmittedText = true;
204
- writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, { type: 'text', text: '' }));
205
628
  }
206
- emittedTextLength += safeText.length;
207
- writeSSEEvent(res, 'content_block_delta', buildContentBlockDelta(contentBlockIndex, { type: 'text_delta', text: safeText }));
208
629
  }
209
630
  }
210
631
  }
@@ -225,62 +646,152 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
225
646
  resSocketForAbort.off('close', onResClose);
226
647
  }
227
648
  }
228
- // Success requires ALL of: sawDone, wasCommitted, no thrown error, no client abort.
229
- // Every failure path emits a streaming `error` and withholds `message_stop`.
649
+ // Success requires ALL of: sawDone, wasCommitted, no terminal error, no thrown
650
+ // error, no client abort. `terminalErrorMessage` is set when a stream done event
651
+ // arrives with `finishReason=error` (or other in-band model error paths) — those
652
+ // turns must route to the failure epilogue so we emit a streaming `error` and
653
+ // withhold `message_stop`. Every failure path emits a streaming `error` and
654
+ // withholds `message_stop`.
230
655
  const committed = wasCommitted();
231
- const successful = sawDone && committed && thrownError == null && !clientAborted;
656
+ const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !clientAborted;
232
657
  if (successful) {
233
658
  const stopReason = terminalStopReason ?? 'end_turn';
234
- writeSSEEvent(res, 'message_delta', buildMessageDelta(stopReason, terminalNumTokens, terminalPromptTokens));
659
+ writeSSEEvent(res, 'message_delta', buildMessageDelta(stopReason, terminalNumTokens, terminalPromptTokens, terminalCachedTokens, terminalPerformance, serverTiming, matchedStopSequence));
660
+ // HTTP/1.1 chunked-encoding trailer: report the engine's cache-hit
661
+ // count once the SSE stream has settled. The header has to wait
662
+ // for `terminalCachedTokens` because `beginSSE` flushes response
663
+ // headers before the dispatch returns. Trailer-aware clients
664
+ // (curl `--trailer-name`, custom HTTP libraries, the verbose
665
+ // logger's response listener) get the authoritative value;
666
+ // SSE-only clients get the same value via the `usage.cache_read_input_tokens`
667
+ // field on `message_delta`. The `Trailer: X-Cached-Tokens` header
668
+ // was announced before `beginSSE` flushed (see messages.ts call site).
669
+ if (typeof terminalCachedTokens === 'number' && terminalCachedTokens > 0) {
670
+ try {
671
+ res.addTrailers({ 'X-Cached-Tokens': String(terminalCachedTokens) });
672
+ }
673
+ catch {
674
+ // res.addTrailers throws if headers/trailers were not announced
675
+ // up front — non-fatal; the SSE usage field still carries the value.
676
+ }
677
+ }
235
678
  await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
679
+ endSSE(res);
680
+ return { ok: true, suppressedToolCalls };
681
+ }
682
+ // Close any dangling content block so the error frame lands at a clean state,
683
+ // then emit the streaming error. Never emit `message_stop` here — pairing it
684
+ // with an error would tell the client the turn completed cleanly.
685
+ if (hasEmittedThinking && !hasEmittedText) {
686
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
687
+ }
688
+ else if (hasEmittedText) {
689
+ writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
690
+ }
691
+ let message;
692
+ if (thrownError != null) {
693
+ message = thrownError.message;
694
+ }
695
+ else if (clientAborted) {
696
+ message = 'client disconnected before the stream completed';
697
+ }
698
+ else if (terminalErrorMessage != null) {
699
+ message = terminalErrorMessage;
700
+ }
701
+ else if (sawDone) {
702
+ message = 'model refused to commit the turn';
236
703
  }
237
704
  else {
238
- // Close any dangling content block so the error frame lands at a clean state,
239
- // then emit the streaming error. Never emit `message_stop` here — pairing it
240
- // with an error would tell the client the turn completed cleanly.
241
- if (hasEmittedThinking && !hasEmittedText) {
242
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex - 1));
243
- }
244
- else if (hasEmittedText) {
245
- writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
246
- }
247
- let message;
248
- if (thrownError != null) {
249
- message = thrownError.message;
250
- }
251
- else if (clientAborted) {
252
- message = 'client disconnected before the stream completed';
253
- }
254
- else if (terminalErrorMessage != null) {
255
- message = terminalErrorMessage;
256
- }
257
- else if (sawDone) {
258
- message = 'model refused to commit the turn';
259
- }
260
- else {
261
- message = 'stream ended without a done event';
262
- }
263
- // The streaming `error` event is the Anthropic terminal on the failure path.
264
- await flushTerminalSSE(res, 'error', { type: 'error', error: { type: 'api_error', message } }, visibility);
705
+ message = 'stream ended without a done event';
265
706
  }
707
+ // The streaming `error` event is the Anthropic terminal on the failure path.
708
+ await flushTerminalSSE(res, 'error', { type: 'error', error: { type: 'api_error', message } }, visibility);
266
709
  endSSE(res);
710
+ return { ok: false, suppressedToolCalls };
267
711
  }
268
- // Session routing
269
- /** Prime a fresh session with the full history and run a single turn. */
270
- async function runSessionNonStreaming(session, messages, config) {
712
+ /** Prime a session with the full history and run a single turn. */
713
+ async function runSessionNonStreaming(session, messages, config, resetNativeCache) {
714
+ // Dual-branch reset gated by the caller's native-cache policy:
715
+ //
716
+ // * Full native reset (`resetNativeCache === true`) — run a full
717
+ // `session.reset()`. A fresh JS session does NOT imply a fresh
718
+ // native cache — the underlying `SessionCapableModel` is shared
719
+ // across `ChatSession` lifetimes via `ModelRegistry`, and its
720
+ // native `cached_token_history` persists across requests. After
721
+ // the native refactor moved the unconditional wipe out of
722
+ // `chat_session_start_sync` into the miss branch of
723
+ // `verify_cache_prefix_direct`, skipping the wipe here would
724
+ // silently reuse whatever prefix happened to overlap with the
725
+ // previous (unrelated) request — the cross-request
726
+ // cache-affinity side channel documented at length in
727
+ // `responses.ts` (around the matching `runSessionNonStreaming`
728
+ // branches). Only registry HITS are authorized for cache reuse.
729
+ //
730
+ // * Preserve native cache (`resetNativeCache === false`) — run the JS-only
731
+ // `resetPreservingNativeCacheForWarmReuse` so the registry-leased
732
+ // native KV cache stays alive for `verify_cache_prefix_direct` or
733
+ // the paged adapter's content-addressed prefix lookup to recover
734
+ // the reused prefix on this turn. `primeHistory` requires
735
+ // `turnCount === 0`, which the helper guarantees by wiping
736
+ // JS-side state only.
737
+ if (resetNativeCache) {
738
+ await session.reset();
739
+ }
740
+ else {
741
+ await resetPreservingNativeCacheForWarmReuse(session);
742
+ }
271
743
  session.primeHistory(messages);
272
- return await session.startFromHistory(config);
744
+ const initialTurns = session.turns;
745
+ const result = await session.startFromHistory(config);
746
+ // Mirror the streaming-side dual-gate (`streamResult.ok &&
747
+ // outcome.wasCommitted()`) and the sibling `/v1/responses` adopt
748
+ // gate. `ChatSession.startFromHistory` advances `turnCount`
749
+ // unconditionally on a clean resolve, so `session.turns >
750
+ // initialTurns` alone never trips today — every native error path
751
+ // throws. The `finishReason !== 'error'` clause defends the
752
+ // invariant LOCALLY so a future Rust change that resolves
753
+ // `chat_session_start_sync` with `Ok(finish_reason="error")` cannot
754
+ // silently poison the warm slot.
755
+ const committed = session.turns > initialTurns && result.finishReason !== 'error';
756
+ return { result, committed };
273
757
  }
274
- function runSessionStreaming(session, messages, config, signal) {
758
+ async function runSessionStreaming(session, messages, config, signal, resetNativeCache) {
759
+ // Validate the exact canonical history before resetting either JS or native
760
+ // state. This preserves a clean HTTP 400 path for context overflow while
761
+ // keeping SSE header latency independent from image processing/prefill.
762
+ const constrainedConfig = await session.preflightContextCapacity(messages, config);
763
+ // Same dual-branch reset as `runSessionNonStreaming`: native-reset
764
+ // requests wipe both JS and native state to block the cross-request
765
+ // cache-affinity leak described at length in `responses.ts`; native-
766
+ // preserving requests wipe JS-only so the non-paged warm slot or the
767
+ // paged content-addressed cache can recover a verified native prefix.
768
+ // `initialTurns` MUST be captured AFTER the reset zeroes `turns` so
769
+ // the committed check reads correctly.
770
+ if (resetNativeCache) {
771
+ await session.reset();
772
+ }
773
+ else {
774
+ await resetPreservingNativeCacheForWarmReuse(session);
775
+ }
275
776
  session.primeHistory(messages);
276
777
  const initialTurns = session.turns;
277
778
  return {
278
- stream: session.startFromHistoryStream(config, signal),
779
+ stream: session.startFromHistoryStream(constrainedConfig, signal),
279
780
  wasCommitted: () => session.turns > initialTurns,
280
781
  };
281
782
  }
282
783
  // Public handler
283
- export async function handleCreateMessage(res, body, registry, httpReq) {
784
+ export async function handleCreateMessage(res, body, registry, httpReq, idleSweeper, resolveModel, modelWorkCoordinator) {
785
+ const handlerStartedAt = Date.now();
786
+ let serverModelResolveMs;
787
+ // Split observability for the resolve path: a request that arrives
788
+ // milliseconds after a peer's cold-load should not be billed the full
789
+ // load latency as if it drove the load itself. `serverLoadWaitMs`
790
+ // captures wall-clock spent blocked on the writer lock (whether
791
+ // waiting on a peer or self-loading); `serverLoadOwner` is true only
792
+ // when this request acquired the lock without contention.
793
+ let serverLoadWaitMs;
794
+ let serverLoadOwner;
284
795
  if (body == null || typeof body !== 'object') {
285
796
  sendAnthropicBadRequest(res, 'Request body must be a JSON object');
286
797
  return;
@@ -297,12 +808,108 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
297
808
  sendAnthropicBadRequest(res, 'Missing required field: max_tokens');
298
809
  return;
299
810
  }
811
+ if (body.max_tokens > MAX_OUTPUT_TOKENS) {
812
+ // The field is present and a positive integer but too large: the native
813
+ // `ChatConfig.max_new_tokens` is `i32`, and NAPI truncates a JS integer
814
+ // above `i32::MAX` to a NEGATIVE value (then clamped to 0 → a silent empty
815
+ // completion), so an over-large budget must 400 with a clear message
816
+ // rather than be reported as "missing" or silently no-op.
817
+ sendAnthropicBadRequest(res, `Field "max_tokens" must be an integer between 1 and ${MAX_OUTPUT_TOKENS}`);
818
+ return;
819
+ }
300
820
  for (const msg of body.messages) {
301
821
  if (msg == null || typeof msg !== 'object') {
302
822
  sendAnthropicBadRequest(res, 'Each message must be a non-null object');
303
823
  return;
304
824
  }
305
825
  }
826
+ // Run the Anthropic→internal mapping BEFORE the lazy-load hook.
827
+ //
828
+ // Background: in `mlx launch claude` mode `resolveModel` may load a
829
+ // 27GB model from disk (~30s) on first sight of an unknown name. If
830
+ // we then fail mapping (unsupported role, malformed tool block, etc.)
831
+ // we've burned a load — and possibly evicted the currently-resident
832
+ // model — just to return 400 a moment later. Mapping is a pure
833
+ // transform with no side effects, so it's safe to hoist above
834
+ // resolveModel and use as a cheap pre-flight gate.
835
+ let mappedMessages;
836
+ let mappedConfig;
837
+ // Client-supplied `stop_sequences`, normalized by the mapper (absent/empty
838
+ // dropped). Threaded into the streaming + non-streaming handlers, which own
839
+ // the detection/truncation. `ChatConfig` has no native stop field, so this
840
+ // rides alongside `config` from the mapper.
841
+ let mappedStopSequences;
842
+ try {
843
+ ({
844
+ messages: mappedMessages,
845
+ config: mappedConfig,
846
+ stopSequences: mappedStopSequences,
847
+ } = mapAnthropicRequest(body));
848
+ }
849
+ catch (err) {
850
+ sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
851
+ return;
852
+ }
853
+ // Lazy-load hook: give the host a chance to register the requested
854
+ // model before we look it up. Errors bubble up to the handler's
855
+ // top-level catch which returns 500.
856
+ //
857
+ // The load is bracketed by `idleSweeper.withSuspendedDrains` so the
858
+ // post-request drain timer armed by the PREVIOUS request's
859
+ // `endRequest()` cannot fire mid-load. In `mlx launch claude` mode
860
+ // `resolveModel` may invoke a 30s `loadModel()` on first sight of an
861
+ // unknown name; if the prior request's matching `endRequest()`
862
+ // armed the default 30s drain immediately before this load began,
863
+ // the timer would otherwise call `clearCache()` while weight
864
+ // materialization was still allocating through the Metal free pool —
865
+ // exactly the hot-load race `withSuspendedDrains` exists to prevent.
866
+ // The wrapper handles try/finally itself and is a pass-through on
867
+ // the disabled sweeper, so the bracket is unconditional whenever
868
+ // a sweeper is supplied.
869
+ if (resolveModel) {
870
+ // A throw here (bad model path, corrupt weights, native loader failure)
871
+ // would otherwise bubble up to the outer `createHandler` catch which
872
+ // emits the OpenAI-shape `{ error: ... }` envelope via `sendInternalError`.
873
+ // This endpoint is Anthropic; clients parse the
874
+ // `{ type: 'error', error: { type, message } }` shape, so we must
875
+ // serialize the failure through `sendAnthropicInternalError` here. Mirrors
876
+ // the `mapAnthropicRequest` try/catch above.
877
+ try {
878
+ const resolveStartedAt = Date.now();
879
+ const runResolve = () => idleSweeper ? idleSweeper.withSuspendedDrains(() => resolveModel(body.model)) : resolveModel(body.model);
880
+ if (modelWorkCoordinator) {
881
+ // Use the instrumented variant so we can tell whether this
882
+ // request actually drove the load (owner) or merely parked
883
+ // behind a peer's in-flight load. Without the split, two
884
+ // requests racing into a 60s cold-load both report
885
+ // `resolve_ms=60000` and observers can't tell which one paid
886
+ // the cost vs. inherited the wait.
887
+ //
888
+ // The coordinator internally partitions the call into a wait
889
+ // phase (`acquireWrite()`) and an own-execution phase (`fn`)
890
+ // so `waitMs + ownMs` covers the total elapsed time without
891
+ // overlap. We plumb them straight through into the matching
892
+ // observability fields:
893
+ // - owner driving a cold load → waitMs ≈ 0, ownMs ≈ load duration
894
+ // - follower parked behind peer → waitMs ≈ peer load, ownMs ≈ 0
895
+ // - already-loaded fast path → waitMs ≈ 0, ownMs ≈ 0
896
+ // This matches the documented contract in `timing.ts` where
897
+ // `server_model_resolve_ms` excludes peer-wait time.
898
+ const outcome = await modelWorkCoordinator.withModelLoadInstrumented(runResolve);
899
+ serverLoadOwner = outcome.owner;
900
+ serverLoadWaitMs = outcome.waitMs;
901
+ serverModelResolveMs = outcome.ownMs;
902
+ }
903
+ else {
904
+ await runResolve();
905
+ serverModelResolveMs = Date.now() - resolveStartedAt;
906
+ }
907
+ }
908
+ catch (err) {
909
+ sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
910
+ return;
911
+ }
912
+ }
306
913
  const model = registry.get(body.model);
307
914
  if (!model) {
308
915
  sendAnthropicNotFound(res, `Model "${body.model}" not found`);
@@ -331,22 +938,51 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
331
938
  abortController.abort();
332
939
  };
333
940
  let abortListenersAttached = false;
941
+ // Idle-sweeper bracket flags — hoisted so the outer `finally` can
942
+ // observe whether the `beginRequest()` bump ever happened. Early
943
+ // validation-failure returns skip the bump and therefore also skip
944
+ // the matching `endRequest()`. `idleRequestEnded` is the `done`
945
+ // flag that guarantees the decrement fires exactly once regardless
946
+ // of which finalize path — outer `finally`, `finish`, `close`,
947
+ // `error` — wins the race.
948
+ //
949
+ // Listeners are attached EAGERLY at `beginRequest()` time, not
950
+ // lazily from the outer `finally`. The round-4 review surfaced a
951
+ // leak where a terminal socket event fired before the outer
952
+ // `finally` ran: the lazy attach saw `writableEnded === false` at
953
+ // check time, attached listeners on a socket whose terminal event
954
+ // had already been emitted, and `endRequest()` then never fired,
955
+ // leaving `inFlight` pinned above zero and the sweeper permanently
956
+ // armed.
957
+ let idleRequestStarted = false;
958
+ let idleRequestEnded = false;
959
+ let idleListenersAttached = false;
960
+ const finalizeIdleRequest = () => {
961
+ if (!idleRequestStarted)
962
+ return;
963
+ if (idleRequestEnded)
964
+ return;
965
+ idleRequestEnded = true;
966
+ idleSweeper?.endRequest();
967
+ };
968
+ const onFinalizeEvent = () => {
969
+ finalizeIdleRequest();
970
+ };
334
971
  try {
335
972
  const sessionReg = lease.registry;
973
+ mappedConfig = applyOutputTokenLimit(mappedConfig, sessionReg.outputTokenLimit);
974
+ mappedConfig = applyClaudeCodeTitleFastPath(mappedConfig, body);
336
975
  // Snapshot the monotonic instance id so the in-mutex re-read can detect a
337
976
  // hot-swap that lands between lease acquisition and mutex entry. Unlike
338
977
  // `/v1/responses`, the Anthropic handler has no stored-identity check
339
978
  // downstream to catch the race later.
340
979
  const preLockInstanceId = lease.instanceId;
341
- let messages;
342
- let config;
343
- try {
344
- ({ messages, config } = mapAnthropicRequest(body));
345
- }
346
- catch (err) {
347
- sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
348
- return;
349
- }
980
+ // `mapAnthropicRequest` already ran (and succeeded) above as a cheap
981
+ // pre-flight gate before `resolveModel` so a malformed request can't
982
+ // trigger a multi-second model load just to 400 a moment later.
983
+ const messages = mappedMessages;
984
+ const config = mappedConfig;
985
+ const stopSequences = mappedStopSequences;
350
986
  // Canonicalize every assistant fan-out's trailing tool block against its
351
987
  // declared sibling order. Several native session backends pair tool results
352
988
  // to fan-out calls POSITIONALLY (not by id), so caller-reversed sibling
@@ -359,18 +995,11 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
359
995
  }
360
996
  // The system prompt is baked into `messages` and replayed via `startFromHistory`,
361
997
  // so it cannot leak across requests. We still pass a canonicalized form to
362
- // `getOrCreate` to keep the registry API uniform with `/v1/responses`. Arrays
363
- // are JSON-stringified; plain strings pass through.
364
- let requestedSystem;
365
- if (typeof body.system === 'string') {
366
- requestedSystem = body.system;
367
- }
368
- else if (body.system != null) {
369
- requestedSystem = JSON.stringify(body.system);
370
- }
371
- else {
372
- requestedSystem = null;
373
- }
998
+ // `getOrCreate` to keep the registry API uniform with `/v1/responses`. The
999
+ // helper is shared with `mapAnthropicRequest`'s system loop so the cache-key
1000
+ // view and the mapped messages can never drift — both drop the rotating
1001
+ // Anthropic billing-header block (cf. `canonicalizeSystemForCacheKey`).
1002
+ const requestedSystem = canonicalizeSystemForCacheKey(body.system);
374
1003
  // Per-model execution mutex. Every dispatch through `/v1/messages` serializes
375
1004
  // with every dispatch through `/v1/responses` for the same model binding.
376
1005
  // The native `SessionCapableModel` is a single mutable resource (shared
@@ -394,8 +1023,35 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
394
1023
  }
395
1024
  abortListenersAttached = true;
396
1025
  const streamSignal = abortController.signal;
1026
+ // Bracket the native-model dispatch with the idle sweeper.
1027
+ // Scoped here (past validation, before any native prefill /
1028
+ // decode) so purely observational endpoints and pre-validation
1029
+ // rejections do not push the sweeper's pending-drain timer out.
1030
+ //
1031
+ // Attach the terminal-event listeners BEFORE any `await` — the
1032
+ // round-4 fix for a leak where a fast terminal event fired
1033
+ // before the outer `finally` attached its listeners, leaving
1034
+ // `inFlight` pinned above zero. `finalizeIdleRequest` is
1035
+ // idempotent (guarded by `idleRequestEnded`) so whichever path
1036
+ // wins — listeners, outer `finally`, or a pre-dispatch early
1037
+ // return — the decrement fires exactly once.
1038
+ idleSweeper?.beginRequest();
1039
+ idleRequestStarted = true;
1040
+ res.once('finish', onFinalizeEvent);
1041
+ res.once('close', onFinalizeEvent);
1042
+ res.once('error', onFinalizeEvent);
1043
+ idleListenersAttached = true;
397
1044
  try {
398
- await sessionReg.withExclusive(async () => {
1045
+ const mutexQueuedAt = Date.now();
1046
+ const runInference = () => withAdmissionControlledInference(sessionReg, modelWorkCoordinator, async () => {
1047
+ const serverTiming = {
1048
+ server_model_resolve_ms: serverModelResolveMs,
1049
+ server_load_wait_ms: serverLoadWaitMs,
1050
+ server_load_owner: serverLoadOwner,
1051
+ server_queue_ms: Date.now() - mutexQueuedAt,
1052
+ server_pre_inference_ms: Date.now() - handlerStartedAt,
1053
+ ...resolveServerTuningForUsage(),
1054
+ };
399
1055
  // Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
400
1056
  // `withExclusive`, so a concurrent re-register of the same friendly name
401
1057
  // could silently dispatch this request through a stale model. Any drift
@@ -414,34 +1070,255 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
414
1070
  `request — if the swap was intentional, the new binding will service the retry cleanly.`);
415
1071
  return;
416
1072
  }
417
- const session = sessionReg.getOrCreate(null, requestedSystem).session;
418
- // `X-Session-Cache` observability header: `/v1/messages` is
419
- // stateless every request allocates a fresh `ChatSession` via
420
- // `getOrCreate(null, …)` — so the status is always `fresh`. Emit
421
- // it anyway to keep the header contract uniform with
422
- // `/v1/responses`, and set it before any `writeHead` / SSE
423
- // `beginSSE` so it lands on both JSON and SSE responses.
424
- res.setHeader('X-Session-Cache', 'fresh');
1073
+ // Per-model session selection for `/v1/messages` reuse.
1074
+ //
1075
+ // Two paths, gated on whether the underlying native model has
1076
+ // the block-paged KV cache adapter active
1077
+ // (`hasBlockPagedCache()` captured at load time from
1078
+ // `<Inner>::paged_adapter.is_some()` and surfaced by the
1079
+ // `SessionCapableModel` structural interface):
1080
+ //
1081
+ // * **Paged-active** (Qwen3 + LFM2 + Gemma4 today; Qwen3.5
1082
+ // dense + Qwen3.5 MoE once their perf trade-off is
1083
+ // decided). Allocate a fresh `ChatSession` per request via
1084
+ // `createFreshSession()`, do NOT touch the warm slot.
1085
+ // Cross-turn / cross-conversation prefix reuse is handled
1086
+ // entirely by the native `BlockAllocator`'s prefix-hash
1087
+ // table: SYS blocks shared across requests are refcounted
1088
+ // transparently, so two parallel `/v1/messages` requests
1089
+ // sharing a system prompt both run on distinct
1090
+ // `ChatSession` objects but reference the SAME physical
1091
+ // KV blocks. The JS-side warm slot would only serialize
1092
+ // them and force one into cold replay.
1093
+ //
1094
+ // * **Non-paged** (Qwen3.5 dense + MoE — default-OFF pending
1095
+ // a perf decision against the compiled C++ flat path;
1096
+ // the Qianfan-OCR VLM — no adapter wired). Fall through to
1097
+ // `getOrCreateWarmAny`, which is the ONLY cross-conversation
1098
+ // reuse mechanism these models have. The Anthropic Messages
1099
+ // API is stateless on the wire (no `previous_response_id`,
1100
+ // clients don't propagate `prompt_cache_key`), so without
1101
+ // the warm slot every turn is a full cold start.
1102
+ //
1103
+ // The `hasBlockPagedCache?()` getter is optional on the
1104
+ // structural interface so the `QianfanOCRModel` VLM (which
1105
+ // has no paged-adapter wiring) still satisfies the type
1106
+ // contract — a missing getter falls into the non-paged branch
1107
+ // here.
1108
+ //
1109
+ // Adoption stays keyed by the literal sentinel
1110
+ // `MESSAGES_WARM_SLOT_ID = '__msg_warm__'`. The Anthropic
1111
+ // Messages API never produces a `previous_response_id` clients
1112
+ // could echo back (and the OpenAI side mints `resp_*` ids),
1113
+ // so cross-endpoint capture via tier-1 is impossible by
1114
+ // construction — no `/v1/responses` request can collide with
1115
+ // the sentinel through the tier-1 path.
1116
+ //
1117
+ // The two endpoints DO share the single warm slot under the
1118
+ // registry's single-warm invariant on the non-paged path: a
1119
+ // `/v1/messages` turn following a `/v1/responses` turn can
1120
+ // evict (and vice versa). On the paged path neither side
1121
+ // touches the warm slot, so cross-endpoint contention
1122
+ // disappears.
1123
+ //
1124
+ // The `prompt_cache_key` request field is still NOT exposed
1125
+ // on this endpoint. Cross-conversation block-level cache
1126
+ // reuse on paged-active models is now driven by native
1127
+ // content-addressing instead of the JS warm slot, so adding
1128
+ // the field is no longer a prerequisite for that use case.
1129
+ const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
1130
+ const lookup = pagedActive ? sessionReg.createFreshSession() : sessionReg.getOrCreateWarmAny(requestedSystem);
1131
+ const session = lookup.session;
1132
+ // `X-Session-Cache` observability header.
1133
+ //
1134
+ // Non-paged path:
1135
+ // * Non-streaming: set the optimistic `prefix_hit` value
1136
+ // BEFORE dispatch on `lookup.hit` (so the header is on the
1137
+ // wire even if the dispatch throws) and demote
1138
+ // post-dispatch to `fresh` when the warm slot was leased
1139
+ // but native prefix reuse did not actually happen
1140
+ // (`result.cachedTokens === 0`). `res.end` has not fired
1141
+ // yet, so the overwrite still lands on the wire.
1142
+ // * Streaming: emits `streaming` to signal the authoritative
1143
+ // post-dispatch value rides on the SSE stream
1144
+ // (`message_delta.usage.cache_read_input_tokens` and the
1145
+ // `X-Cached-Tokens` HTTP trailer, set below in
1146
+ // `handleStreamingNative` once `terminalCachedTokens` is
1147
+ // known). Reporting `fresh` here would be a lie — the
1148
+ // paged engine routinely returns `cachedTokens > 0` on
1149
+ // turn-2+ and the prior `'fresh'` default falsely advertised
1150
+ // a cache miss. The previous comment documented this as
1151
+ // intentional but it was a logging bug.
1152
+ //
1153
+ // Paged path:
1154
+ // * Non-streaming: `lookup.hit` is always `false` (we
1155
+ // `createFreshSession`); the post-dispatch promotion
1156
+ // branch flips `prefix_hit` when the native engine
1157
+ // reports `cachedTokens > 0`, which is the authoritative
1158
+ // signal that the block allocator's content-addressed
1159
+ // reuse picked up shared SYS blocks on this turn.
1160
+ // * Streaming: same `streaming` value as non-paged; the SSE
1161
+ // `usage.cache_read_input_tokens` field carries the
1162
+ // authoritative value.
1163
+ //
1164
+ // Header values: `'fresh' | 'prefix_hit' | 'streaming'`. The
1165
+ // `'streaming'` value tells operators to read the SSE
1166
+ // `message_delta.usage.cache_read_input_tokens` for the
1167
+ // resolved cache-hit count (or `X-Cached-Tokens` trailer if
1168
+ // the client supports HTTP trailers).
1169
+ let sessionCacheStatus = body.stream === true ? 'streaming' : lookup.hit ? 'prefix_hit' : 'fresh';
1170
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1171
+ // HTTP/1.1 chunked-encoding trailer announcement for streaming.
1172
+ // The actual value is filled in by `handleStreamingNative`
1173
+ // once it has captured `terminalCachedTokens` from the final
1174
+ // SSE chunk.
1175
+ if (body.stream === true) {
1176
+ res.setHeader('Trailer', 'X-Cached-Tokens');
1177
+ }
425
1178
  // Outer catch branches on `responseMode` (not `res.headersSent`, which
426
1179
  // flips in `writeHead` before the body lands) so a crash after
427
1180
  // `writeHead(application/json)` cannot leak SSE frames into a JSON body.
428
1181
  const visibility = createVisibility();
429
1182
  try {
430
1183
  if (body.stream === true) {
431
- const outcome = runSessionStreaming(session, messages, config, streamSignal);
432
- await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility);
1184
+ // On the paged path the underlying native cache is the
1185
+ // sole reuse mechanism, so preserve it even though the JS
1186
+ // `ChatSession` is freshly allocated. The native paged
1187
+ // adapter validates reuse by token/hash before any cached
1188
+ // prefix is trusted, and the MoE GDN checkpoint layer now
1189
+ // follows the same content-checked policy. Non-paged keeps
1190
+ // the original `!lookup.hit` semantics so only warm-slot
1191
+ // hits preserve native cache.
1192
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
1193
+ const outcome = await runSessionStreaming(session, messages, config, streamSignal, resetNativeCache);
1194
+ const streamResult = await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility, config.includeReasoning !== false, stopSequences, serverTiming);
1195
+ // Warm-slot adopt/drop only applies to the non-paged
1196
+ // path. On the paged path the JS-side warm slot plays no
1197
+ // role (block reuse is content-addressed in native), so
1198
+ // we never touch it — the fresh `ChatSession` allocated
1199
+ // for this request is dropped on the floor and GC'd once
1200
+ // the handler scope exits.
1201
+ //
1202
+ // Non-paged dual-gate adopt: BOTH the producer-side commit
1203
+ // signal (`outcome.wasCommitted()`, which reads
1204
+ // `session.turns` bumped in `startFromHistoryStream`'s
1205
+ // `finally`) AND the handler-side success signal
1206
+ // (`streamResult.ok`, true only when we reached the clean
1207
+ // `message_stop` terminal) must be true to adopt. The
1208
+ // producer's `finally` runs on every break — including
1209
+ // client abort, mid-decode throw, and
1210
+ // `finishReason=error` — so `wasCommitted()` alone is NOT
1211
+ // sufficient: it can return `true` after the SSE side
1212
+ // emitted an `error` terminal (not re-thrown by
1213
+ // `handleStreamingNative`), leaving a session whose
1214
+ // observable wire state is failure but whose `turns`
1215
+ // counter advanced. Adopting in that window would seed the
1216
+ // warm slot with a session the next request can lease but
1217
+ // whose history does not match what the client received.
1218
+ //
1219
+ // Mirrors `responses.ts` (around line 3277) where the
1220
+ // analogous gate combines `committed`, `handlerError`, and
1221
+ // `streamFailureMode === null` — the producer-side commit
1222
+ // and a clean handler-side terminal must both hold before
1223
+ // the session is reachable from a subsequent request.
1224
+ if (!pagedActive) {
1225
+ if (streamResult.ok && outcome.wasCommitted() && !streamResult.suppressedToolCalls) {
1226
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null);
1227
+ }
1228
+ else {
1229
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1230
+ }
1231
+ }
433
1232
  }
434
1233
  else {
1234
+ // See the streaming branch above for the rationale on
1235
+ // preserving native cache on the paged path.
1236
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
435
1237
  // Native `chatSessionStart` has no AbortSignal yet — disconnect handling
436
1238
  // lives inside `handleNonStreaming` / `endJson`.
437
- const result = await runSessionNonStreaming(session, messages, config);
438
- await handleNonStreaming(res, result, body, visibility);
1239
+ const outcome = await runSessionNonStreaming(session, messages, config, resetNativeCache);
1240
+ const result = outcome.result;
1241
+ // Re-classify the `X-Session-Cache` header.
1242
+ //
1243
+ // Non-paged: a warm-slot hit that did NOT actually produce
1244
+ // native prefix reuse (`cachedTokens === 0` — e.g.
1245
+ // tokenizer change, system prompt drift squeaking past
1246
+ // the byte-equal compare via some upstream rewrite) gets
1247
+ // demoted from `prefix_hit` back to `fresh`.
1248
+ //
1249
+ // Paged: `lookup.hit` is always `false` so we entered
1250
+ // with `sessionCacheStatus = 'fresh'`. Promote to
1251
+ // `prefix_hit` when the native engine reports
1252
+ // `cachedTokens > 0` — that's the authoritative signal
1253
+ // that `BlockAllocator`'s content-addressed prefix lookup
1254
+ // recovered shared SYS blocks on this turn. `res.end` has
1255
+ // not fired yet (`handleNonStreaming` is what flushes via
1256
+ // `endJson`), so the overwrite still lands on the wire.
1257
+ if (lookup.hit && result.cachedTokens === 0) {
1258
+ sessionCacheStatus = 'fresh';
1259
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1260
+ }
1261
+ else if (pagedActive && result.cachedTokens > 0) {
1262
+ sessionCacheStatus = 'prefix_hit';
1263
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1264
+ }
1265
+ // Companion `X-Cached-Tokens` header: emitted only when
1266
+ // reuse genuinely happened, so operators can spot a stale
1267
+ // `prefix_hit` claim from telemetry alone.
1268
+ if (result.cachedTokens > 0) {
1269
+ res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
1270
+ }
1271
+ await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
1272
+ // Non-paged success: adopt the warm slot only when the
1273
+ // dispatch actually committed. Mirrors the streaming-side
1274
+ // dual-gate at `streamResult.ok && outcome.wasCommitted()`
1275
+ // above and the sibling `/v1/responses` adopt gate, so the
1276
+ // local invariant — "never adopt an uncommitted session"
1277
+ // — is enforced by the same check on both wire formats and
1278
+ // both endpoints. Today every native failure throws (and
1279
+ // routes through the inner catch below), so the gate is
1280
+ // dead code on the current Rust paths; it defends the
1281
+ // invariant LOCALLY so a future native change that
1282
+ // resolves `chat_session_start_sync` with
1283
+ // `Ok(finish_reason="error")` cannot silently poison the
1284
+ // warm slot. Drop on the uncommitted branch matches the
1285
+ // streaming-side `else { drop(...) }` so the sentinel does
1286
+ // not accumulate stale entries from earlier turns.
1287
+ //
1288
+ // Paged success: never adopt — block-level reuse is
1289
+ // already in the native cache, and adopting would
1290
+ // re-introduce the cross-endpoint warm-slot eviction
1291
+ // that paged is supposed to eliminate.
1292
+ if (!pagedActive) {
1293
+ if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
1294
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null);
1295
+ }
1296
+ else {
1297
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1298
+ }
1299
+ }
439
1300
  }
440
1301
  }
441
1302
  catch (err) {
1303
+ // A failed turn on the non-paged path must not leave a
1304
+ // poisoned warm slot for the next request to lease — drop
1305
+ // the sentinel before emitting the error response.
1306
+ // Streaming half-failures are already covered by the
1307
+ // `wasCommitted()` gate above; this catch handles
1308
+ // non-streaming throws and any pre-handler failures from
1309
+ // the streaming path. The paged path never adopts, so the
1310
+ // drop is a no-op there but kept unconditional for
1311
+ // simplicity (the registry treats `drop` of an absent key
1312
+ // as a no-op).
1313
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
442
1314
  const message = err instanceof Error ? err.message : 'Unknown error during inference';
443
1315
  if (visibility.responseMode === null) {
444
- sendAnthropicInternalError(res, message);
1316
+ if (isContextCapacityError(err)) {
1317
+ sendAnthropicBadRequest(res, message);
1318
+ }
1319
+ else {
1320
+ sendAnthropicInternalError(res, message);
1321
+ }
445
1322
  }
446
1323
  else if (visibility.responseMode === 'json') {
447
1324
  // Already committed to JSON — destroy the socket rather than corrupt the body.
@@ -456,7 +1333,9 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
456
1333
  // SSE: best-effort streaming `error`, but only if no terminal landed
457
1334
  // (a double terminal would confuse the client state machine).
458
1335
  if (!visibility.terminalEmitted) {
459
- writeFallbackErrorSSE(res, 'error', { error: { type: 'api_error', message } });
1336
+ writeFallbackErrorSSE(res, 'error', {
1337
+ error: { type: 'api_error', message },
1338
+ });
460
1339
  }
461
1340
  try {
462
1341
  endSSE(res);
@@ -467,6 +1346,7 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
467
1346
  }
468
1347
  }
469
1348
  });
1349
+ await runInference();
470
1350
  }
471
1351
  catch (err) {
472
1352
  // Admission-control rejection from the per-model queue cap
@@ -507,5 +1387,21 @@ export async function handleCreateMessage(res, body, registry, httpReq) {
507
1387
  // `unregister()` held against this lease finalises its teardown here
508
1388
  // when the in-flight counter drops to zero.
509
1389
  registry.releaseDispatchLease(leaseModel);
1390
+ // Belt-and-suspenders: call `finalize()` unconditionally here.
1391
+ // The eagerly-attached `finish`/`close`/`error` listeners almost
1392
+ // always win the race, but we still fire here to cover
1393
+ // pathological cases where the terminal event never arrives —
1394
+ // e.g. a synthetic mock, or a pre-dispatch early return that
1395
+ // skipped the attach entirely. `finalizeIdleRequest` is
1396
+ // idempotent (guarded by `idleRequestEnded`) so the double-fire
1397
+ // is a no-op. Detach afterwards so the listeners don't pin the
1398
+ // handler scope past return.
1399
+ finalizeIdleRequest();
1400
+ if (idleListenersAttached) {
1401
+ res.removeListener('finish', onFinalizeEvent);
1402
+ res.removeListener('close', onFinalizeEvent);
1403
+ res.removeListener('error', onFinalizeEvent);
1404
+ idleListenersAttached = false;
1405
+ }
510
1406
  }
511
1407
  }