@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.
- package/dist/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +72 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +22 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +608 -85
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +69 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +70 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +95 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +201 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +37 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +79 -7
- package/dist/server.d.ts +193 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +173 -10
- package/dist/session-registry.d.ts +279 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +518 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- 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`.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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, {
|
|
557
|
+
writeSSEEvent(res, 'content_block_start', buildContentBlockStart(contentBlockIndex, {
|
|
558
|
+
type: 'text',
|
|
559
|
+
text: '',
|
|
560
|
+
}));
|
|
193
561
|
}
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
229
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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`.
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// `
|
|
424
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
438
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|
}
|