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